diff --git a/web/packages/teleterm/build_resources/icon-linux/tray.png b/web/packages/teleterm/build_resources/icon-linux/tray.png new file mode 100644 index 0000000000000..cb7eafa415379 Binary files /dev/null and b/web/packages/teleterm/build_resources/icon-linux/tray.png differ diff --git a/web/packages/teleterm/build_resources/icon-macTemplate@2x.png b/web/packages/teleterm/build_resources/icon-macTemplate@2x.png new file mode 100644 index 0000000000000..a889deef41536 Binary files /dev/null and b/web/packages/teleterm/build_resources/icon-macTemplate@2x.png differ diff --git a/web/packages/teleterm/electron-builder-config.js b/web/packages/teleterm/electron-builder-config.js index ee385f0c43a21..05f22dfa11a80 100644 --- a/web/packages/teleterm/electron-builder-config.js +++ b/web/packages/teleterm/electron-builder-config.js @@ -144,6 +144,8 @@ module.exports = { }, ].filter(Boolean), }, + // Copy the tray icon to resources. + extraResources: ['build_resources/icon-macTemplate@2x.png'], dmg: { artifactName: '${productName}-${version}-${arch}.${ext}', // Turn off blockmaps since we don't support automatic updates. @@ -206,6 +208,8 @@ module.exports = { from: env.CONNECT_MSGFILE_DLL_PATH, to: './bin/msgfile.dll', }, + // Copy the tray icon to resources. + 'build_resources/icon-win.ico', ].filter(Boolean), }, nsis: { @@ -245,6 +249,8 @@ module.exports = { from: 'build_resources/linux/apparmor-profile', to: './apparmor-profile', }, + // Copy the tray icon to resources. + 'build_resources/icon-linux/tray.png', ].filter(Boolean), }, directories: { diff --git a/web/packages/teleterm/src/main.ts b/web/packages/teleterm/src/main.ts index f6b0d03fee4fb..97dacca52f07f 100644 --- a/web/packages/teleterm/src/main.ts +++ b/web/packages/teleterm/src/main.ts @@ -38,6 +38,8 @@ import { createFileLoggerService, LoggerColor } from 'teleterm/services/logger'; import * as types from 'teleterm/types'; import { assertUnreachable } from 'teleterm/ui/utils'; +import { setTray } from './tray'; + if (!app.isPackaged) { // Sets app name and data directories to Electron. // Allows running packaged and non-packaged Connect at the same time. @@ -81,7 +83,11 @@ async function initializeApp(): Promise { }); nativeTheme.themeSource = configService.get('theme').value; - const windowsManager = new WindowsManager(appStateFileStorage, settings); + const windowsManager = new WindowsManager( + appStateFileStorage, + settings, + configService + ); process.on('uncaughtException', (error, origin) => { logger.error('Uncaught exception', origin, error); @@ -155,9 +161,19 @@ async function initializeApp(): Promise { } }); + // On Windows/Linux: Re-launching the app while it's already running + // triggers 'second-instance' (because of app.requestSingleInstanceLock()). + // + // On macOS: Re-launching the app (from places like Finder, Spotlight, or Dock) + // does not trigger 'second-instance'. Instead, the system emits 'activate'. + // However, launching the app outside the desktop manager (e.g., from the command + // line) does trigger 'second-instance'. app.on('second-instance', () => { windowsManager.focusWindow(); }); + app.on('activate', () => { + windowsManager.focusWindow(); + }); // Since setUpDeepLinks adds another listener for second-instance, it's important to call it after // the listener which calls windowsManager.focusWindow. This way the focus will be brought to the @@ -195,6 +211,10 @@ async function initializeApp(): Promise { enableWebHandlersProtection(); windowsManager.createWindow(); + + if (configService.get('runInBackground').value) { + setTray(settings, { show: () => windowsManager.showWindow() }); + } }) .catch(error => { const message = 'Could not create the main app window'; diff --git a/web/packages/teleterm/src/mainProcess/fixtures/mocks.ts b/web/packages/teleterm/src/mainProcess/fixtures/mocks.ts index e0eec52f45a88..a779c4f025b2e 100644 --- a/web/packages/teleterm/src/mainProcess/fixtures/mocks.ts +++ b/web/packages/teleterm/src/mainProcess/fixtures/mocks.ts @@ -197,6 +197,11 @@ export class MockMainProcessClient implements MainProcessClient { } { return { cleanup: () => undefined }; } + subscribeToIsInBackgroundMode(): { + cleanup: () => void; + } { + return { cleanup: () => undefined }; + } } export const makeRuntimeSettings = ( diff --git a/web/packages/teleterm/src/mainProcess/mainProcessClient.ts b/web/packages/teleterm/src/mainProcess/mainProcessClient.ts index 72f747a9d37cc..dc56c2a4832c1 100644 --- a/web/packages/teleterm/src/mainProcess/mainProcessClient.ts +++ b/web/packages/teleterm/src/mainProcess/mainProcessClient.ts @@ -249,5 +249,19 @@ export default function createMainProcessClient(): MainProcessClient { ipcRenderer.removeListener(RendererIpc.OpenAppUpdateDialog, listener), }; }, + subscribeToIsInBackgroundMode: listener => { + const ipcListener = (_, { isInBackgroundMode }) => { + listener({ isInBackgroundMode }); + }; + + ipcRenderer.addListener(RendererIpc.IsInBackgroundMode, ipcListener); + return { + cleanup: () => + ipcRenderer.removeListener( + RendererIpc.IsInBackgroundMode, + ipcListener + ), + }; + }, }; } diff --git a/web/packages/teleterm/src/mainProcess/runtimeSettings.ts b/web/packages/teleterm/src/mainProcess/runtimeSettings.ts index 588c79e5e5e84..7dba5d660af39 100644 --- a/web/packages/teleterm/src/mainProcess/runtimeSettings.ts +++ b/web/packages/teleterm/src/mainProcess/runtimeSettings.ts @@ -30,7 +30,7 @@ const { argv, env } = process; const RESOURCES_PATH = app.isPackaged ? process.resourcesPath - : path.join(__dirname, '../../../../'); + : path.join(__dirname, '../../..'); const TSH_BIN_ENV_VAR = 'CONNECT_TSH_BIN_PATH'; // __dirname of this file in dev mode is teleport/web/packages/teleterm/build/app/main @@ -201,7 +201,7 @@ function getBinaryPaths(): { binDir?: string; tshBinPath: string } { } export function getAssetPath(...paths: string[]): string { - return path.join(RESOURCES_PATH, 'assets', ...paths); + return path.join(RESOURCES_PATH, 'build_resources', ...paths); } /** diff --git a/web/packages/teleterm/src/mainProcess/types.ts b/web/packages/teleterm/src/mainProcess/types.ts index a8145a8fd8e1b..e5749c8183335 100644 --- a/web/packages/teleterm/src/mainProcess/types.ts +++ b/web/packages/teleterm/src/mainProcess/types.ts @@ -224,6 +224,11 @@ export type MainProcessClient = { subscribeToOpenAppUpdateDialog(listener: () => void): { cleanup: () => void; }; + subscribeToIsInBackgroundMode( + listener: (opts: { isInBackgroundMode: boolean }) => void + ): { + cleanup: () => void; + }; }; export type ChildProcessAddresses = { @@ -331,6 +336,7 @@ export enum RendererIpc { DeepLinkLaunch = 'renderer-deep-link-launch', OpenAppUpdateDialog = 'renderer-open-app-update-dialog', AppUpdateEvent = 'renderer-app-update-event', + IsInBackgroundMode = 'renderer-is-in-background-mode', } export enum MainProcessIpc { diff --git a/web/packages/teleterm/src/mainProcess/windowsManager.test.ts b/web/packages/teleterm/src/mainProcess/windowsManager.test.ts index 49aef45f6a009..5db7d497b0ebb 100644 --- a/web/packages/teleterm/src/mainProcess/windowsManager.test.ts +++ b/web/packages/teleterm/src/mainProcess/windowsManager.test.ts @@ -21,6 +21,7 @@ import { BrowserWindow } from 'electron'; +import { createMockConfigService } from 'teleterm/services/config/fixtures/mocks'; import { createMockFileStorage } from 'teleterm/services/fileStorage/fixtures/mocks'; import { makeRuntimeSettings } from './fixtures/mocks'; @@ -73,7 +74,8 @@ describe('waitForWindowFocus', () => { const makeWindowsManager = () => { const windowsManager = new WindowsManager( createMockFileStorage(), - makeRuntimeSettings() + makeRuntimeSettings(), + createMockConfigService({}) ); let isFocused = false; @@ -84,6 +86,8 @@ const makeWindowsManager = () => { }), isFocused: jest.fn().mockImplementation(() => isFocused), isMinimized: jest.fn().mockReturnValue(false), + isVisible: jest.fn().mockReturnValue(true), + isDestroyed: jest.fn().mockReturnValue(false), } as Partial; windowsManager['window'] = mockWindow as BrowserWindow; diff --git a/web/packages/teleterm/src/mainProcess/windowsManager.ts b/web/packages/teleterm/src/mainProcess/windowsManager.ts index cc8ad80be9554..d5e47a61ccee8 100644 --- a/web/packages/teleterm/src/mainProcess/windowsManager.ts +++ b/web/packages/teleterm/src/mainProcess/windowsManager.ts @@ -22,6 +22,7 @@ import * as url from 'node:url'; import { app, BrowserWindow, + dialog, ipcMain, Menu, nativeTheme, @@ -36,11 +37,16 @@ import { RuntimeSettings, WindowsManagerIpc, } from 'teleterm/mainProcess/types'; +import { ConfigService } from 'teleterm/services/config'; import { FileStorage } from 'teleterm/services/fileStorage'; import { darkTheme, lightTheme } from 'teleterm/ui/ThemeProvider/theme'; type WindowState = Rectangle; +interface RunInBackgroundState { + notified?: boolean; +} + export class WindowsManager { private storageKey = 'windowState'; private logger = new Logger('WindowsManager'); @@ -57,10 +63,16 @@ export class WindowsManager { reject: (error: Error) => void; }; private readonly windowUrl: string; + /** + * Tracks if the window was hidden via `enterBackgroundMode()` rather than + * by the OS (e.g. Command+H). + */ + private isInBackgroundMode: boolean; constructor( private fileStorage: FileStorage, - private settings: RuntimeSettings + private settings: RuntimeSettings, + private configService: ConfigService ) { this.selectionContextMenu = Menu.buildFromTemplate([{ role: 'copy' }]); this.frontendAppInit = { @@ -125,11 +137,32 @@ export class WindowsManager { }, }); - window.once('close', () => { + let isAppQuitting = false; + // Triggered when the app itself initiates shutdown (e.g., via app.quit()), + // not when the user manually closes the window. + app.on('before-quit', () => { + isAppQuitting = true; + }); + + window.on('close', async e => { this.saveWindowState(window); - this.frontendAppInit.reject( - new Error('Window was closed before frontend app got initialized') - ); + + if (isAppQuitting || !this.configService.get('runInBackground').value) { + this.frontendAppInit.reject( + new Error('Window was closed before frontend app got initialized') + ); + return; + } + + e.preventDefault(); + + const shouldRun = await this.confirmIfShouldRunInBackgroundOnce(); + if (shouldRun) { + this.enterBackgroundMode(); + return; + } + // Retry closing. + window.close(); }); // shows the window when the DOM is ready, so we don't have a brief flash of a blank screen @@ -198,20 +231,69 @@ export class WindowsManager { ); } + /** Shows the window when it's hidden or minimized. */ + showWindow(): void { + if (!this.isWindowUsable()) { + return; + } + + if (this.window.isMinimized()) { + this.window.restore(); + } + + if (this.window.isVisible()) { + this.window.focus(); + return; + } + + this.window.show(); + if (this.isInBackgroundMode) { + this.window.webContents.send(RendererIpc.IsInBackgroundMode, { + isInBackgroundMode: false, + }); + void app.dock?.show(); + this.isInBackgroundMode = false; + } + } + + /** + * Hides the window if it's visible. + * On macOS, it also hides the dock icon. + */ + enterBackgroundMode(): void { + if (!this.isWindowUsable()) { + return; + } + + if (!this.window.isVisible()) { + return; + } + + this.window.hide(); + this.window.webContents.send(RendererIpc.IsInBackgroundMode, { + isInBackgroundMode: true, + }); + // One side effect to be aware of: + // If you close the app window in one macOS space, switch to another space, + // and then show the app again, macOS will return you to the original space. + // If you close the window again, macOS automatically switches back + // to the space where you requested showing the window. + // This behavior can feel a bit awkward. + app.dock?.hide(); + this.isInBackgroundMode = true; + } + /** * focusWindow is for situations where the app has privileges to do so, for example in a scenario * where the user attempts to launch a second instance of the app – the same process that the user * interacted with asks for its window to receive focus. */ focusWindow(): void { - if (!this.window) { + if (!this.isWindowUsable()) { return; } - if (this.window.isMinimized()) { - this.window.restore(); - } - + this.showWindow(); this.window.focus(); } @@ -223,7 +305,7 @@ export class WindowsManager { * expired, Connect should receive focus and show an appropriate message to the user. */ forceFocusWindow(): void { - if (!this.window) { + if (!this.isWindowUsable()) { return; } @@ -263,12 +345,10 @@ export class WindowsManager { // https://github.com/electron/electron/issues/2867#issuecomment-142480964 // https://github.com/electron/electron/issues/2867#issuecomment-142511956 + this.showWindow(); + app.dock?.bounce('informational'); - // app.focus() alone doesn't un-minimize the window if the window is minimized. - if (this.window.isMinimized()) { - this.window.restore(); - } app.focus({ steal: true }); } @@ -385,6 +465,46 @@ export class WindowsManager { ...getPositionAndSize(), }; } + + private async confirmIfShouldRunInBackgroundOnce(): Promise { + const runInBackgroundState = this.fileStorage.get( + 'runInBackground' + ) as RunInBackgroundState; + if ( + runInBackgroundState?.notified || + // If the value is set in the config file, do not notify too. + this.configService.get('runInBackground').metadata.isStored + ) { + return true; + } + + const isMac = this.settings.platform === 'darwin'; + + const { response } = await dialog.showMessageBox(this.window, { + type: 'question', + message: isMac + ? 'Keep Teleport Connect running in the menu bar?' + : 'Keep Teleport Connect running in the system tray?', + detail: + 'VNet and connections to databases, Kubernetes clusters, and apps will remain active.', + buttons: ['Keep Running', 'Quit'], + noLink: true, + defaultId: 0, + }); + + const state: RunInBackgroundState = { notified: true }; + this.fileStorage.put('runInBackground', state); + + const keepRunning = response === 0; + if (!keepRunning) { + this.configService.set('runInBackground', false); + } + return keepRunning; + } + + private isWindowUsable(): boolean { + return this.window && !this.window.isDestroyed(); + } } /** diff --git a/web/packages/teleterm/src/services/config/appConfigSchema.ts b/web/packages/teleterm/src/services/config/appConfigSchema.ts index 2c63bf352f44a..92a6c7c516a64 100644 --- a/web/packages/teleterm/src/services/config/appConfigSchema.ts +++ b/web/packages/teleterm/src/services/config/appConfigSchema.ts @@ -63,6 +63,12 @@ export const createAppConfigSchema = (settings: RuntimeSettings) => { .describe( 'Skips the version check and hides the version compatibility warning when logging in to a cluster.' ), + runInBackground: z + .boolean() + .default(settings.platform === 'darwin' || settings.platform === 'win32') + .describe( + 'Keeps the app running in the menu bar/system tray even when the main window is closed. On Linux, displaying the system tray icon may require installing shell extensions.' + ), /** * This value can be provided by the user and is unsanitized. This means that it cannot be directly interpolated * in a styled component or used in CSS, as it may inject malicious CSS code. diff --git a/web/packages/teleterm/src/tray.ts b/web/packages/teleterm/src/tray.ts new file mode 100644 index 0000000000000..3e877622fc9e7 --- /dev/null +++ b/web/packages/teleterm/src/tray.ts @@ -0,0 +1,66 @@ +/** + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { Menu, nativeImage, NativeImage, Tray } from 'electron'; + +import { getAssetPath } from 'teleterm/mainProcess/runtimeSettings'; +import { RuntimeSettings } from 'teleterm/mainProcess/types'; + +export function setTray( + runtimeSettings: RuntimeSettings, + window: { show(): void } +): void { + const tray = new Tray( + getIcon(runtimeSettings), + // Random GUIDs that allows the icon to retain its position between relaunches. + runtimeSettings.dev + ? 'b3f163ae-bba3-4513-9593-ce186a3c3eb7' + : 'acf0cb59-0f9e-412a-8973-9ee803bc39f6' + ); + + // On Windows, the app tray menu is displayed on the right mouse click. + // The left mouse click should open the window. + if (runtimeSettings.platform === 'win32') { + tray.on('click', () => window.show()); + } + + const contextMenu = Menu.buildFromTemplate([ + { + label: 'Open Teleport Connect', + click: () => window.show(), + }, + { type: 'separator' }, + { label: 'Quit', role: 'quit' }, + ]); + tray.setContextMenu(contextMenu); +} + +function getIcon(runtimeSettings: RuntimeSettings): string | NativeImage { + switch (runtimeSettings.platform) { + case 'darwin': + const image = nativeImage.createFromPath( + getAssetPath('icon-macTemplate@2x.png') + ); + image.setTemplateImage(true); + return image; + case 'win32': + return getAssetPath('icon-win.ico'); + case 'linux': + return getAssetPath('icon-linux/tray.png'); + } +} diff --git a/web/packages/teleterm/src/ui/Document/Document.tsx b/web/packages/teleterm/src/ui/Document/Document.tsx index cae91f31adcef..0bda8e040a9ad 100644 --- a/web/packages/teleterm/src/ui/Document/Document.tsx +++ b/web/packages/teleterm/src/ui/Document/Document.tsx @@ -16,11 +16,13 @@ * along with this program. If not, see . */ -import React from 'react'; +import React, { ReactNode, useState } from 'react'; import { Flex } from 'design'; import { useRefAutoFocus } from 'shared/hooks'; +import { useIsInBackgroundMode } from 'teleterm/ui/hooks/useIsInBackgroundMode'; + const Document: React.FC<{ visible: boolean; autoFocusDisabled?: boolean; @@ -53,3 +55,51 @@ const Document: React.FC<{ }; export default Document; + +/** + * Wrapper for sessions that should end when the app is in background mode. + * + * When `connected` and the window goes into the background, this component + * unmounts its children, terminating any session tied to the document + * (e.g. desktop or SSH). The children are restored when the window + * becomes visible again and `visible` is true. + */ +export function ForegroundSession({ + connected, + visible, + children, +}: { + /** When `true`, children are unmounted if the app is in the background. */ + connected: boolean; + /** When `true`, children are mounted. */ + visible: boolean; + children: ReactNode; +}) { + const isInBackgroundMode = useIsInBackgroundMode(); + if (isInBackgroundMode && connected) { + return; + } + + return ( + + {children} + + ); +} + +/** Defers mounting the children until they are visible. */ +function MountWhenVisible({ + visible, + children, +}: { + visible: boolean; + children: ReactNode; +}) { + const [showChildren, setShowChildren] = useState(visible); + + if (!showChildren && visible) { + setShowChildren(true); + } + + return showChildren ? children : undefined; +} diff --git a/web/packages/teleterm/src/ui/Document/index.ts b/web/packages/teleterm/src/ui/Document/index.ts index 16c6ec99d3f61..9294e420735f5 100644 --- a/web/packages/teleterm/src/ui/Document/index.ts +++ b/web/packages/teleterm/src/ui/Document/index.ts @@ -19,3 +19,4 @@ import Document from './Document'; export default Document; +export { ForegroundSession } from './Document'; diff --git a/web/packages/teleterm/src/ui/DocumentDesktopSession/DocumentDesktopSession.tsx b/web/packages/teleterm/src/ui/DocumentDesktopSession/DocumentDesktopSession.tsx index 0fb2220d8c669..29402e81a72f1 100644 --- a/web/packages/teleterm/src/ui/DocumentDesktopSession/DocumentDesktopSession.tsx +++ b/web/packages/teleterm/src/ui/DocumentDesktopSession/DocumentDesktopSession.tsx @@ -33,7 +33,7 @@ import Logger from 'teleterm/logger'; import { MainProcessClient } from 'teleterm/mainProcess/types'; import { cloneAbortSignal, TshdClient } from 'teleterm/services/tshd'; import { useAppContext } from 'teleterm/ui/appContextProvider'; -import Document from 'teleterm/ui/Document'; +import Document, { ForegroundSession } from 'teleterm/ui/Document'; import { useWorkspaceContext } from 'teleterm/ui/Documents'; import { useWorkspaceLoggedInUser } from 'teleterm/ui/hooks/useLoggedInUser'; import { useLogger } from 'teleterm/ui/hooks/useLogger'; @@ -51,6 +51,20 @@ const noOtherSession = () => Promise.resolve(false); export function DocumentDesktopSession(props: { visible: boolean; doc: types.DocumentDesktopSession; +}) { + return ( + + + + ); +} + +function DesktopSessionComponent(props: { + visible: boolean; + doc: types.DocumentDesktopSession; }) { const logger = useLogger('DocumentDesktopSession'); const { desktopUri, login, origin, uri } = props.doc; diff --git a/web/packages/teleterm/src/ui/DocumentTerminal/DocumentTerminal.tsx b/web/packages/teleterm/src/ui/DocumentTerminal/DocumentTerminal.tsx index 18dddd754a483..5b64e67d5cd35 100644 --- a/web/packages/teleterm/src/ui/DocumentTerminal/DocumentTerminal.tsx +++ b/web/packages/teleterm/src/ui/DocumentTerminal/DocumentTerminal.tsx @@ -26,7 +26,7 @@ import { import { TerminalSearch } from 'shared/components/TerminalSearch'; import { useAppContext } from 'teleterm/ui/appContextProvider'; -import Document from 'teleterm/ui/Document'; +import Document, { ForegroundSession } from 'teleterm/ui/Document'; import type * as types from 'teleterm/ui/services/workspacesService'; import { Reconnect } from './Reconnect'; @@ -37,6 +37,22 @@ import { useTshFileTransferHandlers } from './useTshFileTransferHandlers'; export function DocumentTerminal(props: { doc: types.DocumentTerminal; visible: boolean; +}) { + return ( + + + + ); +} + +function TerminalComponent(props: { + doc: types.DocumentTerminal; + visible: boolean; }) { const ctx = useAppContext(); const { configService } = ctx.mainProcessClient; diff --git a/web/packages/teleterm/src/ui/hooks/useIsInBackgroundMode.ts b/web/packages/teleterm/src/ui/hooks/useIsInBackgroundMode.ts new file mode 100644 index 0000000000000..3838f0d3540c5 --- /dev/null +++ b/web/packages/teleterm/src/ui/hooks/useIsInBackgroundMode.ts @@ -0,0 +1,41 @@ +/** + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { useEffect, useState } from 'react'; + +import { useAppContext } from 'teleterm/ui/appContextProvider'; + +/** Returns whether the window is in the background. */ +export function useIsInBackgroundMode() { + const ctx = useAppContext(); + // We assume that the window is visible when the app starts. + // This may change in the future. + const [isInBackground, setIsInBackground] = useState(false); + + useEffect(() => { + const { cleanup } = ctx.mainProcessClient.subscribeToIsInBackgroundMode( + ({ isInBackgroundMode }) => { + setIsInBackground(isInBackgroundMode); + } + ); + + return cleanup; + }, [ctx.mainProcessClient]); + + return isInBackground; +}