diff --git a/web/packages/teleterm/src/main.ts b/web/packages/teleterm/src/main.ts index 85a7c704b4444..6d00ecf561786 100644 --- a/web/packages/teleterm/src/main.ts +++ b/web/packages/teleterm/src/main.ts @@ -60,10 +60,10 @@ if (app.requestSingleInstanceLock()) { app.exit(1); } -function initializeApp(): void { +async function initializeApp(): Promise { updateSessionDataPath(); let devRelaunchScheduled = false; - const settings = getRuntimeSettings(); + const settings = await getRuntimeSettings(); const logger = initMainLogger(settings); logger.info(`Starting ${app.getName()} version ${app.getVersion()}`); const { @@ -76,7 +76,7 @@ function initializeApp(): void { const configService = createConfigService({ configFile: configFileStorage, jsonSchemaFile: configJsonSchemaFileStorage, - platform: settings.platform, + settings, }); nativeTheme.themeSource = configService.get('theme').value; @@ -150,9 +150,6 @@ function initializeApp(): void { // 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 // window before processing the listener for deep links. - // - // The setup must be done synchronously when starting the app, otherwise the listeners won't get - // triggered on macOS if the app is not already running when the user opens a deep link. setUpDeepLinks(logger, windowsManager, settings); const rootClusterProxyHostAllowList = new Set(); diff --git a/web/packages/teleterm/src/mainProcess/contextMenus/tabContextMenu.ts b/web/packages/teleterm/src/mainProcess/contextMenus/tabContextMenu.ts index 402dfb04ff8b5..09dc7dd17655a 100644 --- a/web/packages/teleterm/src/mainProcess/contextMenus/tabContextMenu.ts +++ b/web/packages/teleterm/src/mainProcess/contextMenus/tabContextMenu.ts @@ -21,61 +21,176 @@ import { ipcRenderer, Menu, MenuItemConstructorOptions, + dialog, } from 'electron'; +import { ConfigService } from 'teleterm/services/config'; +import { Shell, makeCustomShellFromPath } from 'teleterm/mainProcess/shell'; +import { + Document, + canDocChangeShell, +} from 'teleterm/ui/services/workspacesService'; + import { TabContextMenuEventChannel, TabContextMenuEventType, TabContextMenuOptions, } from '../types'; -type MainTabContextMenuOptions = Pick; +type MainTabContextMenuOptions = { + document: Document; +}; + +type TabContextMenuEvent = + | { + event: TabContextMenuEventType.ReopenPtyInShell; + item: Shell; + } + | { + event: + | TabContextMenuEventType.Close + | TabContextMenuEventType.CloseOthers + | TabContextMenuEventType.CloseToRight + | TabContextMenuEventType.DuplicatePty; + }; -export function subscribeToTabContextMenuEvent(): void { +export function subscribeToTabContextMenuEvent( + shells: Shell[], + configService: ConfigService +): void { ipcMain.handle( TabContextMenuEventChannel, (event, options: MainTabContextMenuOptions) => { - return new Promise(resolve => { + return new Promise(resolve => { + let preventAutoPromiseResolveOnMenuClose = false; + function getCommonTemplate(): MenuItemConstructorOptions[] { return [ { label: 'Close', - click: () => resolve(TabContextMenuEventType.Close), + click: () => resolve({ event: TabContextMenuEventType.Close }), }, { label: 'Close Others', - click: () => resolve(TabContextMenuEventType.CloseOthers), + click: () => + resolve({ event: TabContextMenuEventType.CloseOthers }), }, { label: 'Close to the Right', - click: () => resolve(TabContextMenuEventType.CloseToRight), + click: () => + resolve({ event: TabContextMenuEventType.CloseToRight }), }, ]; } function getPtyTemplate(): MenuItemConstructorOptions[] { if ( - options.documentKind === 'doc.terminal_shell' || - options.documentKind === 'doc.terminal_tsh_node' + options.document.kind === 'doc.terminal_shell' || + options.document.kind === 'doc.terminal_tsh_node' ) { return [ - { - type: 'separator', - }, { label: 'Duplicate Tab', - click: () => resolve(TabContextMenuEventType.DuplicatePty), + click: () => + resolve({ event: TabContextMenuEventType.DuplicatePty }), }, ]; } } + function getShellTemplate(): MenuItemConstructorOptions[] { + const doc = options.document; + if (!canDocChangeShell(doc)) { + return; + } + const activeShellId = doc.shellId; + const defaultShellId = configService.get('terminal.shell').value; + const customShellPath = configService.get( + 'terminal.customShell' + ).value; + const customShell = + customShellPath && makeCustomShellFromPath(customShellPath); + const shellsWithCustom = [...shells, customShell].filter(Boolean); + const isMoreThanOneShell = shellsWithCustom.length > 1; + return [ + { + type: 'separator', + }, + ...shellsWithCustom.map(shell => ({ + label: shell.friendlyName, + id: shell.id, + type: 'radio' as const, + visible: isMoreThanOneShell, + checked: shell.id === activeShellId, + click: () => { + // Do nothing when the shell doesn't change. + if (shell.id === activeShellId) { + return; + } + resolve({ + event: TabContextMenuEventType.ReopenPtyInShell, + item: shell, + }); + }, + })), + { + label: customShell + ? `Change Custom Shell (${customShell.friendlyName})…` + : 'Select Custom Shell…', + click: async () => { + // By default, when the popup menu is closed, the promise is + // resolved (popup.callback). + // Here we need to prevent this behavior to wait for the file + // to be selected. + // A more universal way of handling this problem: + // https://github.com/gravitational/teleport/pull/45152#discussion_r1723314524 + preventAutoPromiseResolveOnMenuClose = true; + const { filePaths, canceled } = await dialog.showOpenDialog({ + properties: ['openFile'], + defaultPath: customShell.binPath, + }); + if (canceled) { + resolve(undefined); + return; + } + const file = filePaths[0]; + configService.set('terminal.customShell', file); + resolve({ + event: TabContextMenuEventType.ReopenPtyInShell, + item: makeCustomShellFromPath(file), + }); + }, + }, + { + label: 'Default Shell', + visible: isMoreThanOneShell, + type: 'submenu', + sublabel: + shellsWithCustom.find(s => defaultShellId === s.id) + ?.friendlyName || defaultShellId, + submenu: [ + ...shellsWithCustom.map(shell => ({ + label: shell.friendlyName, + id: shell.id, + checked: shell.id === defaultShellId, + type: 'radio' as const, + click: () => { + configService.set('terminal.shell', shell.id); + resolve(undefined); + }, + })), + ], + }, + ]; + } + Menu.buildFromTemplate( - [getCommonTemplate(), getPtyTemplate()] + [getCommonTemplate(), getPtyTemplate(), getShellTemplate()] .filter(Boolean) .flatMap(template => template) ).popup({ - callback: () => resolve(undefined), + callback: () => + !preventAutoPromiseResolveOnMenuClose && resolve(undefined), }); }); } @@ -86,13 +201,19 @@ export async function openTabContextMenu( options: TabContextMenuOptions ): Promise { const mainOptions: MainTabContextMenuOptions = { - documentKind: options.documentKind, + document: options.document, }; - const eventType = await ipcRenderer.invoke( + const response = (await ipcRenderer.invoke( TabContextMenuEventChannel, mainOptions - ); - switch (eventType) { + )) as TabContextMenuEvent | undefined; + // Undefined when the menu gets closed without clicking on any action. + if (!response) { + return; + } + const { event } = response; + + switch (event) { case TabContextMenuEventType.Close: return options.onClose(); case TabContextMenuEventType.CloseOthers: @@ -101,5 +222,9 @@ export async function openTabContextMenu( return options.onCloseToRight(); case TabContextMenuEventType.DuplicatePty: return options.onDuplicatePty(); + case TabContextMenuEventType.ReopenPtyInShell: + return options.onReopenPtyInShell(response.item); + default: + event satisfies never; } } diff --git a/web/packages/teleterm/src/mainProcess/fixtures/mocks.ts b/web/packages/teleterm/src/mainProcess/fixtures/mocks.ts index f6d288931f31a..9b25a5c0f486b 100644 --- a/web/packages/teleterm/src/mainProcess/fixtures/mocks.ts +++ b/web/packages/teleterm/src/mainProcess/fixtures/mocks.ts @@ -31,7 +31,7 @@ export class MockMainProcessClient implements MainProcessClient { this.configService = createConfigService({ configFile: createMockFileStorage(), jsonSchemaFile: createMockFileStorage(), - platform: this.getRuntimeSettings().platform, + settings: this.getRuntimeSettings(), }); } @@ -150,7 +150,10 @@ export const makeRuntimeSettings = ( certsDir: '', kubeConfigsDir: '', logsDir: '', - defaultShell: '', + defaultOsShellId: 'zsh', + availableShells: [ + { id: 'zsh', friendlyName: 'zsh', binPath: '/bin/zsh', binName: 'zsh' }, + ], tshd: { requestedNetworkAddress: '', binaryPath: '', diff --git a/web/packages/teleterm/src/mainProcess/mainProcess.ts b/web/packages/teleterm/src/mainProcess/mainProcess.ts index 1e2c8d35b410f..06722a72c6b55 100644 --- a/web/packages/teleterm/src/mainProcess/mainProcess.ts +++ b/web/packages/teleterm/src/mainProcess/mainProcess.ts @@ -502,7 +502,10 @@ export default class MainProcess { ); subscribeToTerminalContextMenuEvent(this.configService); - subscribeToTabContextMenuEvent(); + subscribeToTabContextMenuEvent( + this.settings.availableShells, + this.configService + ); subscribeToConfigServiceEvents(this.configService); subscribeToFileStorageEvents(this.appStateFileStorage); } diff --git a/web/packages/teleterm/src/mainProcess/runtimeSettings.ts b/web/packages/teleterm/src/mainProcess/runtimeSettings.ts index 757ecbac1cb8f..6b8540ee5ca4f 100644 --- a/web/packages/teleterm/src/mainProcess/runtimeSettings.ts +++ b/web/packages/teleterm/src/mainProcess/runtimeSettings.ts @@ -22,10 +22,9 @@ import path from 'path'; import { app } from 'electron'; -import Logger from 'teleterm/logger'; - import { GrpcServerAddresses, RuntimeSettings } from './types'; import { loadInstallationId } from './loadInstallationId'; +import { getAvailableShells, getDefaultShell } from './shell'; const { argv, env } = process; @@ -57,7 +56,7 @@ const insecure = // flag one level down. (dev && !!env.CONNECT_INSECURE); -export function getRuntimeSettings(): RuntimeSettings { +export async function getRuntimeSettings(): Promise { const userDataDir = app.getPath('userData'); const sessionDataDir = app.getPath('sessionData'); const tempDataDir = app.getPath('temp'); @@ -98,6 +97,7 @@ export function getRuntimeSettings(): RuntimeSettings { // // A workaround is to read the version from `process.env.npm_package_version`. const appVersion = dev ? process.env.npm_package_version : app.getVersion(); + const availableShells = await getAvailableShells(); return { dev, @@ -112,7 +112,8 @@ export function getRuntimeSettings(): RuntimeSettings { binDir, agentBinaryPath: path.resolve(sessionDataDir, 'teleport', 'teleport'), certsDir: getCertsDir(), - defaultShell: getDefaultShell(), + availableShells, + defaultOsShellId: getDefaultShell(availableShells), kubeConfigsDir, logsDir, platform: process.platform, @@ -203,29 +204,6 @@ export function getAssetPath(...paths: string[]): string { return path.join(RESOURCES_PATH, 'assets', ...paths); } -function getDefaultShell(): string { - const logger = new Logger(); - switch (process.platform) { - case 'linux': - case 'darwin': { - const fallbackShell = 'bash'; - const { shell } = os.userInfo(); - - if (!shell) { - logger.error( - `Failed to read ${process.platform} platform default shell, using fallback: ${fallbackShell}.\n` - ); - - return fallbackShell; - } - - return shell; - } - case 'win32': - return 'powershell.exe'; - } -} - /** * Describes what addresses the gRPC servers should attempt to obtain on app startup. */ diff --git a/web/packages/teleterm/src/mainProcess/shell.ts b/web/packages/teleterm/src/mainProcess/shell.ts new file mode 100644 index 0000000000000..ee58e72a1ff27 --- /dev/null +++ b/web/packages/teleterm/src/mainProcess/shell.ts @@ -0,0 +1,136 @@ +/** + * Teleport + * Copyright (C) 2024 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 os from 'node:os'; +import path from 'node:path'; + +import which from 'which'; + +import Logger from 'teleterm/logger'; +import { CUSTOM_SHELL_ID } from 'teleterm/services/config/appConfigSchema'; + +export interface Shell { + /** + * Shell identifier, for example, pwsh.exe or zsh + * (it doesn't have to be the same as the binary name). + * Used as an identifier in the app config or in a document. + * Must be unique. + * */ + id: typeof CUSTOM_SHELL_ID | string; + /** Shell executable, for example, C:\\Windows\system32\pwsh.exe, /bin/zsh. */ + binPath: string; + /** Binary name of the shell executable, for example, pwsh.exe, zsh. */ + binName: string; + /** Friendly name, for example, Windows PowerShell, zsh. */ + friendlyName: string; +} + +export async function getAvailableShells(): Promise { + switch (process.platform) { + case 'linux': + case 'darwin': + return getUnixShells(); + case 'win32': { + return getWindowsShells(); + } + } +} + +export function getDefaultShell(availableShells: Shell[]): string { + switch (process.platform) { + case 'linux': + case 'darwin': { + // There is always a default shell. + return availableShells.at(0).id; + } + case 'win32': + if (availableShells.find(shell => shell.id === 'pwsh.exe')) { + return 'pwsh.exe'; + } + return 'powershell.exe'; + } +} + +async function getUnixShells(): Promise { + const logger = new Logger(); + const { shell } = os.userInfo(); + const binName = path.basename(shell); + if (!shell) { + const fallbackShell = 'bash'; + logger.error( + `Failed to read ${process.platform} platform default shell, using fallback: ${fallbackShell}.\n` + ); + return [ + { + id: fallbackShell, + binPath: fallbackShell, + friendlyName: fallbackShell, + binName: fallbackShell, + }, + ]; + } + + return [{ id: binName, binPath: shell, friendlyName: binName, binName }]; +} + +async function getWindowsShells(): Promise { + const shells = await Promise.all( + [ + { + binName: 'powershell.exe', + friendlyName: 'Windows PowerShell (powershell.exe)', + }, + { + binName: 'pwsh.exe', + friendlyName: 'PowerShell (pwsh.exe)', + }, + { + binName: 'cmd.exe', + friendlyName: 'Command Prompt (cmd.exe)', + }, + { + binName: 'wsl.exe', + friendlyName: 'WSL (wsl.exe)', + }, + ].map(async shell => { + const binPath = await which(shell.binName, { nothrow: true }); + if (!binPath) { + return; + } + + return { + binPath, + binName: shell.binName, + id: shell.binName, + friendlyName: shell.friendlyName, + }; + }) + ); + + return shells.filter(Boolean); +} + +export function makeCustomShellFromPath(shellPath: string): Shell { + const shellBinName = path.basename(shellPath); + return { + id: CUSTOM_SHELL_ID, + binPath: shellPath, + binName: shellBinName, + friendlyName: shellBinName, + }; +} diff --git a/web/packages/teleterm/src/mainProcess/types.ts b/web/packages/teleterm/src/mainProcess/types.ts index 78683cd1067d6..d2aeed78b001a 100644 --- a/web/packages/teleterm/src/mainProcess/types.ts +++ b/web/packages/teleterm/src/mainProcess/types.ts @@ -20,11 +20,13 @@ import { CreateAgentConfigFileArgs } from 'teleterm/mainProcess/createAgentConfi import { DeepLinkParseResult } from 'teleterm/deepLinks'; import { RootClusterUri } from 'teleterm/ui/uri'; -import { Kind } from 'teleterm/ui/services/workspacesService'; +import { Document } from 'teleterm/ui/services/workspacesService'; import { FileStorage } from 'teleterm/services/fileStorage'; import { ConfigService } from '../services/config'; +import { Shell } from './shell'; + export type RuntimeSettings = { /** * dev controls whether the app runs in development mode. This mostly controls what kind of URL @@ -60,7 +62,9 @@ export type RuntimeSettings = { // Before switching to the recommended path, we need to investigate the impact of this change. // https://www.electronjs.org/docs/latest/api/app#appgetpathname logsDir: string; - defaultShell: string; + /** Identifier of default OS shell. */ + defaultOsShellId: string; + availableShells: Shell[]; platform: Platform; agentBinaryPath: string; tshd: { @@ -207,15 +211,12 @@ export interface ClusterContextMenuOptions { } export interface TabContextMenuOptions { - documentKind: Kind; - + document: Document; onClose(): void; - onCloseOthers(): void; - onCloseToRight(): void; - onDuplicatePty(): void; + onReopenPtyInShell(shell: Shell): void; } export const TerminalContextMenuEventChannel = @@ -229,6 +230,7 @@ export enum TabContextMenuEventType { CloseOthers = 'CloseOthers', CloseToRight = 'CloseToRight', DuplicatePty = 'DuplicatePty', + ReopenPtyInShell = 'ReopenPtyInShell', } export enum ConfigServiceEventType { diff --git a/web/packages/teleterm/src/preload.ts b/web/packages/teleterm/src/preload.ts index 9a691d4ebf085..0fde69b7b6b8f 100644 --- a/web/packages/teleterm/src/preload.ts +++ b/web/packages/teleterm/src/preload.ts @@ -73,16 +73,7 @@ async function getElectronGlobals(): Promise { addresses.shared, credentials.shared, runtimeSettings, - { - ssh: { - noResume: mainProcessClient.configService.get('ssh.noResume').value, - }, - terminal: { - windowsBackend: mainProcessClient.configService.get( - 'terminal.windowsBackend' - ).value, - }, - } + mainProcessClient.configService ); const { setupTshdEventContextBridgeService, diff --git a/web/packages/teleterm/src/services/config/appConfigSchema.ts b/web/packages/teleterm/src/services/config/appConfigSchema.ts index 34a85cda610b5..563733ec67bd2 100644 --- a/web/packages/teleterm/src/services/config/appConfigSchema.ts +++ b/web/packages/teleterm/src/services/config/appConfigSchema.ts @@ -18,7 +18,7 @@ import { z } from 'zod'; -import { Platform } from 'teleterm/mainProcess/types'; +import { Platform, RuntimeSettings } from 'teleterm/mainProcess/types'; import { createKeyboardShortcutSchema } from './keyboardShortcutSchema'; @@ -28,11 +28,27 @@ import { createKeyboardShortcutSchema } from './keyboardShortcutSchema'; export type AppConfigSchema = ReturnType; export type AppConfig = z.infer; -export const createAppConfigSchema = (platform: Platform) => { - const defaultKeymap = getDefaultKeymap(platform); - const defaultTerminalFont = getDefaultTerminalFont(platform); +/** ID of the custom shell. When it is set, the shell path should be read from `terminal.customShell`. */ +export const CUSTOM_SHELL_ID = 'custom' as const; - const shortcutSchema = createKeyboardShortcutSchema(platform); +/** + * List of properties that can be modified from the renderer process. + * The motivation for adding this was to make it impossible to change + * `terminal.customShell` from the renderer. + */ +export const CONFIG_MODIFIABLE_FROM_RENDERER: (keyof AppConfig)[] = [ + 'usageReporting.enabled', +]; + +export const createAppConfigSchema = (settings: RuntimeSettings) => { + const defaultKeymap = getDefaultKeymap(settings.platform); + const defaultTerminalFont = getDefaultTerminalFont(settings.platform); + const availableShellIdsWithCustom = [ + ...settings.availableShells.map(({ id }) => id), + CUSTOM_SHELL_ID, + ]; + + const shortcutSchema = createKeyboardShortcutSchema(settings.platform); // `keymap.` prefix is used in `initUi.ts` in a predicate function. return z.object({ @@ -63,9 +79,30 @@ export const createAppConfigSchema = (platform: Platform) => { .describe( '`auto` uses modern ConPTY system if available, which requires Windows 10 (19H1) or above. Set to `winpty` to use winpty even if ConPTY is available.' ), + 'terminal.shell': z + .string() + .default(settings.defaultOsShellId) + .describe( + 'A default terminal shell. It is best to configure it through UI (right click on a terminal tab > Default Shell).' + ) + .refine( + configuredShell => + availableShellIdsWithCustom.some( + shellId => shellId === configuredShell + ), + configuredShell => ({ + message: `Cannot find the shell "${configuredShell}". Available options are: ${availableShellIdsWithCustom.join(', ')}. Using platform default.`, + }) + ), + 'terminal.customShell': z + .string() + .default('') + .describe( + 'Path to a custom shell that is used when terminal.shell is set to "custom".' + ), 'terminal.rightClick': z .enum(['paste', 'copyPaste', 'menu']) - .default(platform === 'win32' ? 'copyPaste' : 'menu') + .default(settings.platform === 'win32' ? 'copyPaste' : 'menu') .describe( '`paste` pastes clipboard content, `copyPaste` copies if text is selected, otherwise pastes, `menu` shows context menu.' ), diff --git a/web/packages/teleterm/src/services/config/configService.test.ts b/web/packages/teleterm/src/services/config/configService.test.ts index dab7e0a39b0d8..88f7be952bdfe 100644 --- a/web/packages/teleterm/src/services/config/configService.test.ts +++ b/web/packages/teleterm/src/services/config/configService.test.ts @@ -18,6 +18,7 @@ import Logger, { NullService } from 'teleterm/logger'; import { createMockFileStorage } from 'teleterm/services/fileStorage/fixtures/mocks'; +import { makeRuntimeSettings } from 'teleterm/mainProcess/fixtures/mocks'; import { createConfigService } from './configService'; @@ -31,7 +32,7 @@ test('stored and default values are combined', () => { const configService = createConfigService({ configFile, jsonSchemaFile: createMockFileStorage(), - platform: 'darwin', + settings: makeRuntimeSettings(), }); expect(configService.getConfigError()).toBeUndefined(); @@ -51,7 +52,7 @@ test('in case of invalid value a default one is returned', () => { const configService = createConfigService({ configFile: configFile, jsonSchemaFile: createMockFileStorage(), - platform: 'darwin', + settings: makeRuntimeSettings(), }); expect(configService.getConfigError()).toStrictEqual({ @@ -84,7 +85,7 @@ test('if config file failed to load correctly the error is returned', () => { const configService = createConfigService({ configFile, jsonSchemaFile: createMockFileStorage(), - platform: 'darwin', + settings: makeRuntimeSettings(), }); expect(configService.getConfigError()).toStrictEqual({ @@ -98,7 +99,7 @@ test('calling set updated the value in store', () => { const configService = createConfigService({ configFile, jsonSchemaFile: createMockFileStorage(), - platform: 'darwin', + settings: makeRuntimeSettings(), }); configService.set('usageReporting.enabled', true); @@ -119,7 +120,7 @@ test('field linking to the json schema and the json schema itself are updated', createConfigService({ configFile, jsonSchemaFile, - platform: 'darwin', + settings: makeRuntimeSettings(), }); expect(configFile.get('$schema')).toBe('config_schema.json'); diff --git a/web/packages/teleterm/src/services/config/configService.ts b/web/packages/teleterm/src/services/config/configService.ts index 56310749bd237..64abe3fcc3401 100644 --- a/web/packages/teleterm/src/services/config/configService.ts +++ b/web/packages/teleterm/src/services/config/configService.ts @@ -21,7 +21,7 @@ import zodToJsonSchema from 'zod-to-json-schema'; import { FileStorage } from 'teleterm/services/fileStorage'; import Logger from 'teleterm/logger'; -import { Platform } from 'teleterm/mainProcess/types'; +import { RuntimeSettings } from 'teleterm/mainProcess/types'; import { createAppConfigSchema, @@ -68,13 +68,13 @@ export interface ConfigService { export function createConfigService({ configFile, jsonSchemaFile, - platform, + settings, }: { configFile: FileStorage; jsonSchemaFile: FileStorage; - platform: Platform; + settings: RuntimeSettings; }): ConfigService { - const schema = createAppConfigSchema(platform); + const schema = createAppConfigSchema(settings); updateJsonSchema({ schema, configFile, jsonSchemaFile }); const { diff --git a/web/packages/teleterm/src/services/config/configServiceClient.ts b/web/packages/teleterm/src/services/config/configServiceClient.ts index 22ad4e0200701..50b29842e24e4 100644 --- a/web/packages/teleterm/src/services/config/configServiceClient.ts +++ b/web/packages/teleterm/src/services/config/configServiceClient.ts @@ -18,6 +18,8 @@ import { ipcMain, ipcRenderer } from 'electron'; +import { CONFIG_MODIFIABLE_FROM_RENDERER } from 'teleterm/services/config/appConfigSchema'; + import { ConfigServiceEventChannel, ConfigServiceEventType, @@ -34,6 +36,11 @@ export function subscribeToConfigServiceEvents( case ConfigServiceEventType.Get: return (event.returnValue = configService.get(item.path)); case ConfigServiceEventType.Set: + if (!CONFIG_MODIFIABLE_FROM_RENDERER.includes(item.path)) { + throw new Error( + `Could not update "${item.path}". This field is readonly in the renderer process.` + ); + } return configService.set(item.path, item.value); case ConfigServiceEventType.GetConfigError: return (event.returnValue = configService.getConfigError()); diff --git a/web/packages/teleterm/src/services/pty/fixtures/mocks.ts b/web/packages/teleterm/src/services/pty/fixtures/mocks.ts index 9cefda658c666..1beeb875b2670 100644 --- a/web/packages/teleterm/src/services/pty/fixtures/mocks.ts +++ b/web/packages/teleterm/src/services/pty/fixtures/mocks.ts @@ -20,7 +20,6 @@ import { IPtyProcess } from 'teleterm/sharedProcess/ptyHost'; import { PtyProcessCreationStatus, PtyServiceClient, - WindowsPty, } from 'teleterm/services/pty'; export class MockPtyProcess implements IPtyProcess { @@ -62,15 +61,17 @@ export class MockPtyProcess implements IPtyProcess { } export class MockPtyServiceClient implements PtyServiceClient { - createPtyProcess(): Promise<{ - process: IPtyProcess; - creationStatus: PtyProcessCreationStatus; - windowsPty: WindowsPty; - }> { + createPtyProcess() { return Promise.resolve({ process: new MockPtyProcess(), creationStatus: PtyProcessCreationStatus.Ok, windowsPty: undefined, + shell: { + id: 'zsh', + friendlyName: 'zsh', + binPath: '/bin/zsh', + binName: 'zsh', + }, }); } } diff --git a/web/packages/teleterm/src/services/pty/ptyHost/buildPtyOptions.test.ts b/web/packages/teleterm/src/services/pty/ptyHost/buildPtyOptions.test.ts index 4f8720d3d2482..d75aff7de3ce1 100644 --- a/web/packages/teleterm/src/services/pty/ptyHost/buildPtyOptions.test.ts +++ b/web/packages/teleterm/src/services/pty/ptyHost/buildPtyOptions.test.ts @@ -18,13 +18,24 @@ import { makeRuntimeSettings } from 'teleterm/mainProcess/fixtures/mocks'; +import Logger, { NullService } from 'teleterm/logger'; + import { ShellCommand, TshLoginCommand, GatewayCliClientCommand, + PtyProcessCreationStatus, } from '../types'; -import { getPtyProcessOptions } from './buildPtyOptions'; +import { getPtyProcessOptions, buildPtyOptions } from './buildPtyOptions'; + +beforeAll(() => { + Logger.init(new NullService()); +}); + +jest.mock('./resolveShellEnv', () => ({ + resolveShellEnvCached: () => Promise.resolve({}), +})); describe('getPtyProcessOptions', () => { describe('pty.gateway-cli-client', () => { @@ -45,12 +56,17 @@ describe('getPtyProcessOptions', () => { }, }; - const { env } = getPtyProcessOptions( - makeRuntimeSettings(), - { ssh: { noResume: false }, windowsPty: { useConpty: true } }, - cmd, - processEnv - ); + const { env } = getPtyProcessOptions({ + settings: makeRuntimeSettings(), + options: { + customShellPath: '', + ssh: { noResume: false }, + windowsPty: { useConpty: true }, + }, + cmd: cmd, + env: processEnv, + shellBinPath: '/bin/zsh', + }); expect(env.processExclusive).toBe('process'); expect(env.cmdExclusive).toBe('cmd'); @@ -68,18 +84,24 @@ describe('getPtyProcessOptions', () => { kind: 'pty.shell', clusterName: 'bar', proxyHost: 'baz', + shellId: 'zsh', env: { cmdExclusive: 'cmd', shared: 'fromCmd', }, }; - const { env } = getPtyProcessOptions( - makeRuntimeSettings(), - { ssh: { noResume: false }, windowsPty: { useConpty: true } }, - cmd, - processEnv - ); + const { env } = getPtyProcessOptions({ + settings: makeRuntimeSettings(), + options: { + customShellPath: '', + ssh: { noResume: false }, + windowsPty: { useConpty: true }, + }, + cmd: cmd, + env: processEnv, + shellBinPath: '/bin/zsh', + }); expect(env.processExclusive).toBe('process'); expect(env.cmdExclusive).toBe('cmd'); @@ -101,14 +123,148 @@ describe('getPtyProcessOptions', () => { leafClusterId: undefined, }; - const { args } = getPtyProcessOptions( - makeRuntimeSettings(), - { ssh: { noResume: true }, windowsPty: { useConpty: true } }, - cmd, - processEnv - ); + const { args } = getPtyProcessOptions({ + settings: makeRuntimeSettings(), + options: { + customShellPath: '', + ssh: { noResume: true }, + windowsPty: { useConpty: true }, + }, + cmd: cmd, + env: processEnv, + shellBinPath: '/bin/zsh', + }); expect(args).toContain('--no-resume'); }); }); }); + +describe('buildPtyOptions', () => { + it('shellId is resolved to the shell object', async () => { + const cmd: ShellCommand = { + kind: 'pty.shell', + clusterName: 'bar', + proxyHost: 'baz', + shellId: 'bash', + }; + + const { shell, creationStatus } = await buildPtyOptions({ + settings: makeRuntimeSettings({ + availableShells: [ + { + id: 'bash', + friendlyName: 'bash', + binPath: '/bin/bash', + binName: 'bash', + }, + ], + }), + options: { + customShellPath: '', + ssh: { noResume: false }, + windowsPty: { useConpty: true }, + }, + cmd, + }); + + expect(shell).toEqual({ + id: 'bash', + binPath: '/bin/bash', + binName: 'bash', + friendlyName: 'bash', + }); + expect(creationStatus).toBe(PtyProcessCreationStatus.Ok); + }); + + it("custom shell path is resolved to the shell object when shellId is 'custom''", async () => { + const cmd: ShellCommand = { + kind: 'pty.shell', + clusterName: 'bar', + proxyHost: 'baz', + shellId: 'custom', + }; + + const { shell, creationStatus } = await buildPtyOptions({ + settings: makeRuntimeSettings(), + options: { + customShellPath: '/custom/shell/path/better-shell', + ssh: { noResume: false }, + windowsPty: { useConpty: true }, + }, + cmd, + }); + + expect(shell).toEqual({ + id: 'custom', + binPath: '/custom/shell/path/better-shell', + binName: 'better-shell', + friendlyName: 'better-shell', + }); + expect(creationStatus).toBe(PtyProcessCreationStatus.Ok); + }); + + it('if the provided shellId is not available, an OS default is returned', async () => { + const cmd: ShellCommand = { + kind: 'pty.shell', + clusterName: 'bar', + proxyHost: 'baz', + shellId: 'no-such-shell', + }; + + const { shell, creationStatus } = await buildPtyOptions({ + settings: makeRuntimeSettings(), + options: { + customShellPath: '', + ssh: { noResume: false }, + windowsPty: { useConpty: true }, + }, + cmd, + }); + + expect(shell).toEqual({ + id: 'zsh', + binPath: '/bin/zsh', + binName: 'zsh', + friendlyName: 'zsh', + }); + expect(creationStatus).toBe(PtyProcessCreationStatus.ShellNotResolved); + }); + + it("Teleport Connect env variables are prepended to the user's WSLENV for wsl.exe", async () => { + const cmd: ShellCommand = { + kind: 'pty.shell', + clusterName: 'bar', + proxyHost: 'baz', + shellId: 'wsl.exe', + }; + + const { processOptions } = await buildPtyOptions({ + settings: makeRuntimeSettings({ + platform: 'win32', + availableShells: [ + { + id: 'wsl.exe', + binName: 'wsl.exe', + friendlyName: '', + binPath: '', + }, + ], + }), + options: { + customShellPath: '', + ssh: { noResume: false }, + windowsPty: { useConpty: true }, + }, + cmd, + processEnv: { + // Simulate the user defined WSLENV var. + WSLENV: 'CUSTOM_VAR', + }, + }); + + expect(processOptions.env.WSLENV).toBe( + 'CUSTOM_VAR:TERM_PROGRAM:TERM_PROGRAM_VERSION:TELEPORT_CLUSTER:TELEPORT_PROXY:TELEPORT_HOME/p:KUBECONFIG/p' + ); + }); +}); diff --git a/web/packages/teleterm/src/services/pty/ptyHost/buildPtyOptions.ts b/web/packages/teleterm/src/services/pty/ptyHost/buildPtyOptions.ts index 1e122404e3f09..a0bb8ca5d6f25 100644 --- a/web/packages/teleterm/src/services/pty/ptyHost/buildPtyOptions.ts +++ b/web/packages/teleterm/src/services/pty/ptyHost/buildPtyOptions.ts @@ -22,12 +22,16 @@ import { RuntimeSettings } from 'teleterm/mainProcess/types'; import { PtyProcessOptions } from 'teleterm/sharedProcess/ptyHost'; import { assertUnreachable } from 'teleterm/ui/utils'; +import { Shell, makeCustomShellFromPath } from 'teleterm/mainProcess/shell'; +import { CUSTOM_SHELL_ID } from 'teleterm/services/config/appConfigSchema'; + import { PtyCommand, PtyProcessCreationStatus, TshKubeLoginCommand, - SshOptions, WindowsPty, + ShellCommand, + SshOptions, } from '../types'; import { @@ -38,17 +42,42 @@ import { type PtyOptions = { ssh: SshOptions; windowsPty: Pick; + customShellPath: string; }; -export async function buildPtyOptions( - settings: RuntimeSettings, - options: PtyOptions, - cmd: PtyCommand -): Promise<{ +const WSLENV_VAR = 'WSLENV'; + +export async function buildPtyOptions({ + settings, + options, + cmd, + processEnv = process.env, +}: { + settings: RuntimeSettings; + options: PtyOptions; + cmd: PtyCommand; + processEnv?: typeof process.env; +}): Promise<{ processOptions: PtyProcessOptions; + shell: Shell; creationStatus: PtyProcessCreationStatus; }> { - return resolveShellEnvCached(settings.defaultShell) + const defaultShell = settings.availableShells.find( + s => s.id === settings.defaultOsShellId + ); + let shell = defaultShell; + let failedToResolveShell = false; + + if (cmd.kind === 'pty.shell') { + const resolvedShell = await resolveShell(cmd, settings, options); + if (!resolvedShell) { + failedToResolveShell = true; + } else { + shell = resolvedShell; + } + } + + return resolveShellEnvCached(shell.binPath) .then(resolvedEnv => ({ shellEnv: resolvedEnv, creationStatus: PtyProcessCreationStatus.Ok, @@ -64,7 +93,7 @@ export async function buildPtyOptions( }) .then(({ shellEnv, creationStatus }) => { const combinedEnv = { - ...process.env, + ...processEnv, ...shellEnv, TERM_PROGRAM: 'Teleport_Connect', TERM_PROGRAM_VERSION: settings.appVersion, @@ -73,24 +102,54 @@ export async function buildPtyOptions( TELEPORT_PROXY: cmd.proxyHost, }; + // The regular env vars are not available in WSL, + // they need to be passed via the special variable WSLENV. + // Note that path variables have /p postfix which translates the paths from Win32 to WSL. + // https://devblogs.microsoft.com/commandline/share-environment-vars-between-wsl-and-windows/ + if (settings.platform === 'win32' && shell.binName === 'wsl.exe') { + const wslEnv = [ + 'TERM_PROGRAM', + 'TERM_PROGRAM_VERSION', + 'TELEPORT_CLUSTER', + 'TELEPORT_PROXY', + 'TELEPORT_HOME/p', + 'KUBECONFIG/p', + ]; + // Preserve the user defined WSLENV and add ours (ours takes precedence). + combinedEnv[WSLENV_VAR] = [combinedEnv[WSLENV_VAR], wslEnv] + .flat() + .join(':'); + } + return { - processOptions: getPtyProcessOptions( - settings, - options, - cmd, - combinedEnv - ), - creationStatus, + processOptions: getPtyProcessOptions({ + settings: settings, + options: options, + cmd: cmd, + env: combinedEnv, + shellBinPath: shell.binPath, + }), + shell, + creationStatus: failedToResolveShell + ? PtyProcessCreationStatus.ShellNotResolved + : creationStatus, }; }); } -export function getPtyProcessOptions( - settings: RuntimeSettings, - options: PtyOptions, - cmd: PtyCommand, - env: typeof process.env -): PtyProcessOptions { +export function getPtyProcessOptions({ + settings, + options, + cmd, + env, + shellBinPath, +}: { + settings: RuntimeSettings; + options: PtyOptions; + cmd: PtyCommand; + env: typeof process.env; + shellBinPath: string; +}): PtyProcessOptions { const useConpty = options.windowsPty?.useConpty; switch (cmd.kind) { @@ -109,7 +168,7 @@ export function getPtyProcessOptions( } return { - path: settings.defaultShell, + path: shellBinPath, args: [], cwd: cmd.cwd, env: { ...env, ...cmd.env }, @@ -137,7 +196,7 @@ export function getPtyProcessOptions( const bashCommandArgs = ['-c', `${kubeLoginCommand};$SHELL`]; const powershellCommandArgs = ['-NoExit', '-c', kubeLoginCommand]; return { - path: settings.defaultShell, + path: shellBinPath, args: isWindows ? powershellCommandArgs : bashCommandArgs, env: { ...env, KUBECONFIG: getKubeConfigFilePath(cmd, settings) }, useConpty, @@ -190,8 +249,18 @@ function prependBinDirToPath( // // Windows seems to construct Path by first taking the system Path env var and adding to it the // user Path env var. - const pathName = settings.platform === 'win32' ? 'Path' : 'PATH'; - env[pathName] = [settings.binDir, env[pathName]] + // + // For process.env on Windows, Path and PATH are the same (case insensitivity). + // Node.js have special setters and getters, so no matter what property you set, + // the single underlying value is updated. However, since we merge many sources + // of env vars into a single object with the object spread (let env = { ...process.env }), + // theses setters and getters are lost. + // The problem happens when user variables and system variables use different + // casing for PATH and Node.js merges them into a single variable, and we have + // to figure out its casing. + // vscode does it the same way. + const pathKey = getPropertyCaseInsensitive(env, 'PATH'); + env[pathKey] = [settings.binDir, env[pathKey]] .map(path => path?.trim()) .filter(Boolean) .join(delimiter); @@ -203,3 +272,28 @@ function getKubeConfigFilePath( ): string { return path.join(settings.kubeConfigsDir, command.kubeConfigRelativePath); } + +async function resolveShell( + cmd: ShellCommand, + settings: RuntimeSettings, + ptyOptions: PtyOptions +): Promise { + if (cmd.shellId !== CUSTOM_SHELL_ID) { + return settings.availableShells.find(s => s.id === cmd.shellId); + } + + const { customShellPath } = ptyOptions; + if (customShellPath) { + return makeCustomShellFromPath(customShellPath); + } +} + +function getPropertyCaseInsensitive( + env: Record, + key: string +): string | undefined { + const pathKeys = Object.keys(env).filter( + k => k.toLowerCase() === key.toLowerCase() + ); + return pathKeys.length > 0 ? pathKeys[0] : key; +} diff --git a/web/packages/teleterm/src/services/pty/ptyHost/resolveShellEnv.ts b/web/packages/teleterm/src/services/pty/ptyHost/resolveShellEnv.ts index 856315d63408d..7d2ac9e2a0dbc 100644 --- a/web/packages/teleterm/src/services/pty/ptyHost/resolveShellEnv.ts +++ b/web/packages/teleterm/src/services/pty/ptyHost/resolveShellEnv.ts @@ -97,7 +97,8 @@ async function resolveUnixShellEnv( const command = `'${process.execPath}' -p '"${mark}" + JSON.stringify(process.env) + "${mark}"'`; // When bash is run with -c, it is considered a non-interactive shell, and it does not read ~/.bashrc, unless is -i specified. // https://unix.stackexchange.com/questions/277312/is-the-shell-created-by-bash-i-c-command-interactive - const shellArgs = shell === '/bin/tcsh' ? ['-ic'] : ['-ilc']; + const shellArgs = + shell === '/bin/tcsh' || shell === '/bin/csh' ? ['-ic'] : ['-cil']; logger.info(`Reading shell ${shell} ${shellArgs} ${command}`); diff --git a/web/packages/teleterm/src/services/pty/ptyService.ts b/web/packages/teleterm/src/services/pty/ptyService.ts index 3b82021e8868d..fc210c042fc2f 100644 --- a/web/packages/teleterm/src/services/pty/ptyService.ts +++ b/web/packages/teleterm/src/services/pty/ptyService.ts @@ -19,38 +19,43 @@ import { ChannelCredentials } from '@grpc/grpc-js'; import { RuntimeSettings } from 'teleterm/mainProcess/types'; +import { ConfigService } from 'teleterm/services/config'; import { buildPtyOptions } from './ptyHost/buildPtyOptions'; import { createPtyHostClient } from './ptyHost/ptyHostClient'; import { createPtyProcess } from './ptyHost/ptyProcess'; -import { PtyServiceClient, PtyOptions } from './types'; +import { PtyServiceClient } from './types'; import { getWindowsPty } from './ptyHost/windowsPty'; export function createPtyService( address: string, credentials: ChannelCredentials, runtimeSettings: RuntimeSettings, - options: PtyOptions + configService: ConfigService ): PtyServiceClient { const ptyHostClient = createPtyHostClient(address, credentials); return { createPtyProcess: async command => { - const windowsPty = getWindowsPty(runtimeSettings, options.terminal); - const { processOptions, creationStatus } = await buildPtyOptions( - runtimeSettings, - { - ssh: options.ssh, + const windowsPty = getWindowsPty(runtimeSettings, { + windowsBackend: configService.get('terminal.windowsBackend').value, + }); + const { processOptions, creationStatus, shell } = await buildPtyOptions({ + settings: runtimeSettings, + options: { + ssh: { noResume: configService.get('ssh.noResume').value }, + customShellPath: configService.get('terminal.customShell').value, windowsPty, }, - command - ); + cmd: command, + }); const ptyId = await ptyHostClient.createPtyProcess(processOptions); // Electron's context bridge doesn't allow to return a class here return { process: createPtyProcess(ptyHostClient, ptyId), creationStatus, + shell, windowsPty, }; }, diff --git a/web/packages/teleterm/src/services/pty/types.ts b/web/packages/teleterm/src/services/pty/types.ts index aaac99a224ede..36a440381964f 100644 --- a/web/packages/teleterm/src/services/pty/types.ts +++ b/web/packages/teleterm/src/services/pty/types.ts @@ -17,12 +17,14 @@ */ import { PtyProcessOptions, IPtyProcess } from 'teleterm/sharedProcess/ptyHost'; +import { Shell } from 'teleterm/mainProcess/shell'; import { PtyEventsStreamHandler } from './ptyHost/ptyEventsStreamHandler'; export enum PtyProcessCreationStatus { Ok = 'Ok', ResolveShellEnvTimeout = 'ResolveShellEnvTimeout', + ShellNotResolved = 'ShellNotResolved', } export interface PtyHostClient { @@ -38,6 +40,7 @@ export type PtyServiceClient = { process: IPtyProcess; creationStatus: PtyProcessCreationStatus; windowsPty: WindowsPty; + shell: Shell; }>; }; @@ -64,6 +67,8 @@ export type ShellCommand = PtyCommandBase & { // The initMessage is rendered on the terminal UI without being written or // read by the underlying PTY. initMessage?: string; + /** Shell identifier. */ + shellId: string; }; export type TshLoginCommand = PtyCommandBase & { @@ -123,8 +128,3 @@ export type SshOptions = { export type TerminalOptions = { windowsBackend: 'auto' | 'winpty'; }; - -export type PtyOptions = { - ssh: SshOptions; - terminal: TerminalOptions; -}; diff --git a/web/packages/teleterm/src/ui/DocumentTerminal/useDocumentTerminal.test.tsx b/web/packages/teleterm/src/ui/DocumentTerminal/useDocumentTerminal.test.tsx index 3280880988f4a..1b4e1560ad797 100644 --- a/web/packages/teleterm/src/ui/DocumentTerminal/useDocumentTerminal.test.tsx +++ b/web/packages/teleterm/src/ui/DocumentTerminal/useDocumentTerminal.test.tsx @@ -29,6 +29,7 @@ import { DocumentTshNode, DocumentTshNodeWithLoginHost, DocumentTshNodeWithServerId, + DocumentPtySession, } from 'teleterm/ui/services/workspacesService'; import { ResourcesService, @@ -82,6 +83,13 @@ const getDocTshNodeWithServerId: () => DocumentTshNodeWithServerId = () => ({ origin: 'resource_table', }); +const getDocPtySession: () => DocumentPtySession = () => ({ + kind: 'doc.terminal_shell', + title: 'Terminal', + uri: '/docs/456', + rootClusterId: 'test', +}); + const getDocTshNodeWithLoginHost: () => DocumentTshNodeWithLoginHost = () => { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { serverId, serverUri, login, ...rest } = getDocTshNodeWithServerId(); @@ -238,6 +246,12 @@ test('useDocumentTerminal shows a warning notification if the call to TerminalsS process: getPtyProcessMock(), creationStatus: PtyProcessCreationStatus.ResolveShellEnvTimeout, windowsPty: undefined, + shell: { + id: 'zsh', + friendlyName: 'zsh', + binPath: '/bin/zsh', + binName: 'zsh', + }, }); jest.spyOn(notificationsService, 'notifyWarning'); @@ -576,6 +590,12 @@ const testSetup = ( process: getPtyProcessMock(), creationStatus: PtyProcessCreationStatus.Ok, windowsPty: undefined, + shell: { + id: 'zsh', + friendlyName: 'zsh', + binPath: '/bin/zsh', + binName: 'zsh', + }, }; }); @@ -597,6 +617,44 @@ const testSetup = ( return { appContext, wrapper, documentsService }; }; +test('shellId is set to a config default when empty', async () => { + const doc = getDocPtySession(); + const { wrapper, appContext } = testSetup(doc); + appContext.configService.set('terminal.shell', 'bash'); + const { terminalsService } = appContext; + + ( + terminalsService.createPtyProcess as jest.MockedFunction< + typeof terminalsService.createPtyProcess + > + ).mockReset(); + jest.spyOn(terminalsService, 'createPtyProcess').mockResolvedValue({ + process: getPtyProcessMock(), + creationStatus: PtyProcessCreationStatus.Ok, + windowsPty: undefined, + shell: { + id: 'zsh', + friendlyName: 'zsh', + binPath: '/bin/zsh', + binName: 'zsh', + }, + }); + + const { result } = renderHook(() => useDocumentTerminal(doc), { wrapper }); + + await waitFor(() => expect(result.current.attempt.status).toBe('success')); + expect(terminalsService.createPtyProcess).toHaveBeenCalledWith({ + shellId: 'bash', + clusterName: 'Test', + cwd: undefined, + kind: 'pty.shell', + proxyHost: 'localhost:3080', + rootClusterId: 'test', + title: 'Terminal', + uri: '/docs/456', + }); +}); + // TODO(ravicious): Add tests for the following cases: // * dispose on unmount when state is success // * removing init command from doc diff --git a/web/packages/teleterm/src/ui/DocumentTerminal/useDocumentTerminal.ts b/web/packages/teleterm/src/ui/DocumentTerminal/useDocumentTerminal.ts index 155cc3e36c200..b4b30c01bcfd7 100644 --- a/web/packages/teleterm/src/ui/DocumentTerminal/useDocumentTerminal.ts +++ b/web/packages/teleterm/src/ui/DocumentTerminal/useDocumentTerminal.ts @@ -25,6 +25,7 @@ import { IAppContext } from 'teleterm/ui/types'; import { DocumentsService, isDocumentTshNodeWithLoginHost, + canDocChangeShell, } from 'teleterm/ui/services/workspacesService'; import { IPtyProcess } from 'teleterm/sharedProcess/ptyHost'; import { useWorkspaceContext } from 'teleterm/ui/Documents'; @@ -40,6 +41,7 @@ import Logger from 'teleterm/logger'; import { ClustersService } from 'teleterm/ui/services/clusters'; import * as tshdGateway from 'teleterm/services/tshd/gateway'; +import type { Shell } from 'teleterm/mainProcess/shell'; import type * as types from 'teleterm/ui/services/workspacesService'; import type * as uri from 'teleterm/ui/uri'; import type * as tsh from 'teleterm/services/tshd/types'; @@ -53,12 +55,27 @@ export function useDocumentTerminal(doc: types.DocumentTerminal) { documentsService.update(doc.uri, { status: 'connecting' }); } + // Add `shellId` before going further. + // When a new document is crated, its `shellId` is empty + // (setting the default shell would require reading it from ConfigService + // in DocumentsService and I wasn't sure about adding more dependencies there). + // Because of that, I decided to initialize this property later. + // `doc.shellId` is used in here, in `useDocumentTerminal` and in `tabContextMenu`. + let docWithDefaultShell: types.DocumentTerminal; + if (canDocChangeShell(doc) && !doc.shellId) { + docWithDefaultShell = { + ...doc, + shellId: ctx.configService.get('terminal.shell').value, + }; + documentsService.update(doc.uri, docWithDefaultShell); + } + try { return await initializePtyProcess( ctx, logger.current, documentsService, - doc + docWithDefaultShell || doc ); } catch (err) { if ('status' in doc) { @@ -234,7 +251,15 @@ async function setUpPtyProcess( getClusterName() ); - const { process: ptyProcess, windowsPty } = await createPtyProcess(ctx, cmd); + const { + process: ptyProcess, + windowsPty, + shell, + } = await createPtyProcess(ctx, cmd); + // Update the document with the shell that was resolved. + // This may be a different shell than the one passed as `shellId` + // (for example, if it is no longer available, the default one will be opened). + documentsService.update(doc.uri, { shellId: shell.id }); if (doc.kind === 'doc.terminal_tsh_node') { ctx.usageService.captureProtocolUse({ @@ -262,19 +287,11 @@ async function setUpPtyProcess( const openContextMenu = () => ctx.mainProcessClient.openTerminalContextMenu(); const refreshTitle = async () => { - // TODO(ravicious): Enable updating cwd in doc.gateway_kube titles by - // moving title-updating logic to DocumentsService. The logic behind - // updating the title should be encapsulated in a single place, so that - // useDocumentTerminal doesn't need to know the details behind the title of - // each document kind. - if (doc.kind !== 'doc.terminal_shell') { - return; - } - - const cwd = await ptyProcess.getCwd(); - documentsService.update(doc.uri, { - cwd, - title: `${cwd || 'Terminal'} · ${getClusterName()}`, + documentsService.refreshPtyTitle(doc.uri, { + shell: shell, + cwd: await ptyProcess.getCwd(), + clusterName: getClusterName(), + runtimeSettings: ctx.mainProcessClient.getRuntimeSettings(), }); }; @@ -328,8 +345,12 @@ async function setUpPtyProcess( async function createPtyProcess( ctx: IAppContext, cmd: PtyCommand -): Promise<{ process: IPtyProcess; windowsPty: WindowsPty }> { - const { process, creationStatus, windowsPty } = +): Promise<{ + process: IPtyProcess; + windowsPty: WindowsPty; + shell: Shell; +}> { + const { process, creationStatus, windowsPty, shell } = await ctx.terminalsService.createPtyProcess(cmd); if (creationStatus === PtyProcessCreationStatus.ResolveShellEnvTimeout) { @@ -341,7 +362,16 @@ async function createPtyProcess( }); } - return { process, windowsPty }; + if ( + cmd.kind === 'pty.shell' && + creationStatus === PtyProcessCreationStatus.ShellNotResolved + ) { + ctx.notificationsService.notifyWarning({ + title: `Requested shell "${cmd.shellId}" is not available`, + }); + } + + return { process, windowsPty, shell }; } // TODO(ravicious): Instead of creating cmd within useDocumentTerminal, make useDocumentTerminal @@ -448,6 +478,7 @@ function createCmd( clusterName, env, initMessage, + shellId: doc.shellId, }; } @@ -457,5 +488,6 @@ function createCmd( proxyHost, clusterName, cwd: doc.cwd, + shellId: doc.shellId, }; } diff --git a/web/packages/teleterm/src/ui/TabHost/TabHost.test.tsx b/web/packages/teleterm/src/ui/TabHost/TabHost.test.tsx index 1237024a97dbf..3e3a6a392a69d 100644 --- a/web/packages/teleterm/src/ui/TabHost/TabHost.test.tsx +++ b/web/packages/teleterm/src/ui/TabHost/TabHost.test.tsx @@ -135,7 +135,7 @@ test('open context menu', async () => { // @ts-expect-error `openTabContextMenu` doesn't know about jest const options: TabContextMenuOptions = openTabContextMenu.mock.calls[0][0]; - expect(options.documentKind).toBe(document.kind); + expect(options.document).toEqual(document); options.onClose(); expect(close).toHaveBeenCalledWith(document.uri); diff --git a/web/packages/teleterm/src/ui/TabHost/TabHost.tsx b/web/packages/teleterm/src/ui/TabHost/TabHost.tsx index d6bb18a852f89..8e59d1ef01668 100644 --- a/web/packages/teleterm/src/ui/TabHost/TabHost.tsx +++ b/web/packages/teleterm/src/ui/TabHost/TabHost.tsx @@ -22,10 +22,12 @@ import { Flex } from 'design'; import { useAppContext } from 'teleterm/ui/appContextProvider'; import * as types from 'teleterm/ui/services/workspacesService/documentsService/types'; +import { canDocChangeShell } from 'teleterm/ui/services/workspacesService/documentsService/types'; import { Tabs } from 'teleterm/ui/Tabs'; import { DocumentsRenderer } from 'teleterm/ui/Documents/DocumentsRenderer'; import { IAppContext } from 'teleterm/ui/types'; import { useKeyboardShortcutFormatters } from 'teleterm/ui/services/keyboardShortcuts'; +import { Shell } from 'teleterm/mainProcess/shell'; import { useTabShortcuts } from './useTabShortcuts'; import { useNewTabOpener } from './useNewTabOpener'; @@ -83,7 +85,7 @@ export function TabHost({ function handleTabContextMenu(doc: types.Document) { ctx.mainProcessClient.openTabContextMenu({ - documentKind: doc.kind, + document: doc, onClose: () => { documentsService.close(doc.uri); }, @@ -96,6 +98,11 @@ export function TabHost({ onDuplicatePty: () => { documentsService.duplicatePtyAndActivate(doc.uri); }, + onReopenPtyInShell(shell: Shell) { + if (canDocChangeShell(doc)) { + documentsService.reopenPtyInShell(doc, shell); + } + }, }); } diff --git a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/documentsService.ts b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/documentsService.ts index 142ea17adec0a..ec3851550e84c 100644 --- a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/documentsService.ts +++ b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/documentsService.ts @@ -43,8 +43,12 @@ import { DocumentTshNode, DocumentTshNodeWithServerId, DocumentClusterQueryParams, + DocumentPtySession, } from './types'; +import type { Shell } from 'teleterm/mainProcess/shell'; +import type { RuntimeSettings } from 'teleterm/mainProcess/types'; + export class DocumentsService { constructor( private getState: () => { documents: Document[]; location: string }, @@ -256,6 +260,8 @@ export class DocumentsService { return { ...activeDocument, uri: routing.getDocUri({ docId: unique() }), + // Do not inherit the shell of this document when opening a new one, use default. + shellId: undefined, ...opts, }; } else { @@ -368,6 +374,45 @@ export class DocumentsService { }); } + refreshPtyTitle( + uri: DocumentUri, + { + shell, + cwd, + clusterName, + runtimeSettings, + }: { + shell: Shell; + cwd: string; + clusterName: string; + runtimeSettings: Pick; + } + ): void { + const doc = this.getDocument(uri); + if (!doc) { + throw Error(`Document ${uri} does not exist`); + } + const omitShellName = + (runtimeSettings.platform === 'linux' || + runtimeSettings.platform === 'darwin') && + shell.id === runtimeSettings.defaultOsShellId; + const shellBinName = !omitShellName && shell.binName; + if (doc.kind === 'doc.terminal_shell') { + this.update(doc.uri, { + cwd, + title: [shellBinName, cwd, clusterName].filter(Boolean).join(' · '), + }); + return; + } + + if (doc.kind === 'doc.gateway_kube') { + const { params } = routing.parseKubeUri(doc.targetUri); + this.update(doc.uri, { + title: [shellBinName, cwd, params.kubeId].filter(Boolean).join(' · '), + }); + } + } + replace(uri: DocumentUri, document: Document): void { const documentToCloseIndex = this.getDocuments().findIndex( doc => doc.uri === uri @@ -380,6 +425,15 @@ export class DocumentsService { this.open(document.uri); } + reopenPtyInShell( + document: T, + shell: Shell + ): void { + // We assign a new URI to render a new document. + const newDocument: T = { ...document, shellId: shell.id, uri: unique() }; + this.replace(document.uri, newDocument); + } + filter(uri: string) { return this.getState().documents.filter(i => i.uri !== uri); } diff --git a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/types.ts b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/types.ts index fca5746185d41..cc95e377520ec 100644 --- a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/types.ts +++ b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/types.ts @@ -154,6 +154,8 @@ export interface DocumentGatewayKube extends DocumentBase { leafClusterId: string | undefined; targetUri: uri.KubeUri; origin: DocumentOrigin; + /** Identifier of the shell to be opened. */ + shellId?: string; // status is used merely to show a progress bar when the gateway is being set up. status: '' | 'connecting' | 'connected' | 'error'; } @@ -200,6 +202,8 @@ export interface DocumentAccessRequests extends DocumentBase { export interface DocumentPtySession extends DocumentBase { kind: 'doc.terminal_shell'; cwd?: string; + /** Identifier of the shell to be opened. */ + shellId?: string; rootClusterId?: string; leafClusterId?: string; } @@ -248,6 +252,16 @@ export function isDocumentTshNodeWithServerId( return doc.kind === 'doc.terminal_tsh_node' && 'serverId' in doc; } +/** + * `DocumentPtySession` and `DocumentGatewayKube` spawn a shell. + * The shell is taken from the `doc.shellId` property. + */ +export function canDocChangeShell( + doc: Document +): doc is DocumentPtySession | DocumentGatewayKube { + return doc.kind === 'doc.terminal_shell' || doc.kind === 'doc.gateway_kube'; +} + export type CreateGatewayDocumentOpts = { gatewayUri?: uri.GatewayUri; targetUri: uri.DatabaseUri | uri.AppUri;