diff --git a/package.json b/package.json index a7600a2aea..2385f2dda5 100644 --- a/package.json +++ b/package.json @@ -74,8 +74,7 @@ "/src/extensions/npm" ], "setupFiles": [ - "/src/jest.setup.ts", - "jest-canvas-mock" + "/src/jest.setup.ts" ], "globalSetup": "/src/jest.timezone.ts", "setupFilesAfterEnv": [ @@ -354,6 +353,7 @@ "@typescript-eslint/eslint-plugin": "^5.29.0", "@typescript-eslint/parser": "^5.29.0", "ansi_up": "^5.1.0", + "canvas": "^2.9.3", "chart.js": "^2.9.4", "circular-dependency-plugin": "^5.2.2", "cli-progress": "^3.11.2", @@ -382,9 +382,9 @@ "ignore-loader": "^0.1.2", "include-media": "^1.4.9", "jest": "^28.1.2", - "jest-canvas-mock": "^2.3.1", "jest-environment-jsdom": "^28.1.1", "jest-fetch-mock": "^3.0.3", + "jest-image-snapshot": "^5.1.0", "jest-mock-extended": "^2.0.6", "make-plural": "^6.2.2", "mini-css-extract-plugin": "^2.6.1", @@ -404,6 +404,7 @@ "react-select-event": "^5.5.0", "react-table": "^7.8.0", "react-window": "^1.8.7", + "resize-observer-polyfill": "^1.5.1", "sass": "^1.53.0", "sass-loader": "^12.6.0", "sharp": "^0.30.7", diff --git a/src/common/fs/stat.injectable.ts b/src/common/fs/stat.injectable.ts new file mode 100644 index 0000000000..7c350c6f51 --- /dev/null +++ b/src/common/fs/stat.injectable.ts @@ -0,0 +1,17 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getInjectable } from "@ogre-tools/injectable"; +import type { Stats } from "fs"; +import fsInjectable from "./fs.injectable"; + +export type Stat = (path: string) => Promise; + +const statInjectable = getInjectable({ + id: "stat", + instantiate: (di): Stat => di.inject(fsInjectable).stat, +}); + +export default statInjectable; diff --git a/src/common/user-store/resolved-shell.injectable.ts b/src/common/user-store/resolved-shell.injectable.ts new file mode 100644 index 0000000000..66e2b068b8 --- /dev/null +++ b/src/common/user-store/resolved-shell.injectable.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { computed } from "mobx"; +import userStoreInjectable from "./user-store.injectable"; + +const resolvedShellInjectable = getInjectable({ + id: "resolved-shell", + instantiate: (di) => { + const store = di.inject(userStoreInjectable); + + return computed(() => store.shell || process.env.SHELL || process.env.PTYSHELL); + }, +}); + +export default resolvedShellInjectable; diff --git a/src/common/user-store/user-store.ts b/src/common/user-store/user-store.ts index b806732735..932b2cbecf 100644 --- a/src/common/user-store/user-store.ts +++ b/src/common/user-store/user-store.ts @@ -102,10 +102,6 @@ export class UserStore extends BaseStore /* implements UserStore return semver.gt(getAppVersion(), this.lastSeenAppVersion); } - @computed get resolvedShell(): string | undefined { - return this.shell || process.env.SHELL || process.env.PTYSHELL; - } - startMainReactions() { // open at system start-up reaction(() => this.openAtLogin, openAtLogin => { diff --git a/src/common/utils/disposer.ts b/src/common/utils/disposer.ts index db71148404..6f94b7c31d 100644 --- a/src/common/utils/disposer.ts +++ b/src/common/utils/disposer.ts @@ -4,22 +4,26 @@ */ export type Disposer = () => void; - -interface Extendable { - push(...vals: T[]): void; +export interface LibraryDisposers { + dispose(): void; } -export type ExtendableDisposer = Disposer & Extendable; +export interface ExtendableDisposer { + (): void; + push(...disposers: (Disposer | LibraryDisposers)[]): void; +} -export function disposer(...args: (Disposer | undefined | null)[]): ExtendableDisposer { - const res = () => { - args.forEach(dispose => dispose?.()); +export function disposer(...args: (Disposer | LibraryDisposers | undefined | null)[]): ExtendableDisposer { + return Object.assign(() => { + args.forEach(d => { + if (typeof d === "function") { + d(); + } else if (d) { + d.dispose(); + } + }); args.length = 0; - }; - - res.push = (...vals: Disposer[]) => { - args.push(...vals); - }; - - return res; + }, { + push: (...vals) => args.push(...vals), + } as Pick); } diff --git a/src/jest.setup.ts b/src/jest.setup.ts index 6e7ef2fc85..bb0575e56c 100644 --- a/src/jest.setup.ts +++ b/src/jest.setup.ts @@ -8,6 +8,7 @@ import configurePackages from "./common/configure-packages"; import { configure } from "mobx"; import { setImmediate } from "timers"; import { TextEncoder, TextDecoder as TextDecoderNode } from "util"; +import ResizeObserver from "resize-observer-polyfill"; // setup default configuration for external npm-packages configurePackages(); @@ -36,3 +37,18 @@ process.on("unhandledRejection", (err: any) => { global.TextEncoder = TextEncoder; global.TextDecoder = TextDecoderNode as unknown as typeof TextDecoder; +Object.defineProperty(window, "matchMedia", { + writable: true, + value: jest.fn().mockImplementation(query => ({ + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), // Deprecated + removeListener: jest.fn(), // Deprecated + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), +}); + +global.ResizeObserver = ResizeObserver; diff --git a/src/main/getDiForUnitTesting.ts b/src/main/getDiForUnitTesting.ts index 51a677ad07..b677455713 100644 --- a/src/main/getDiForUnitTesting.ts +++ b/src/main/getDiForUnitTesting.ts @@ -101,6 +101,9 @@ import { registerMobX } from "@ogre-tools/injectable-extension-for-mobx"; import electronInjectable from "./utils/resolve-system-proxy/electron.injectable"; import type { HotbarStore } from "../common/hotbars/store"; import focusApplicationInjectable from "./electron-app/features/focus-application.injectable"; +import getValidCwdInjectable from "./shell-session/get-valid-cwd.injectable"; +import getCachedShellEnvInjectable from "./shell-session/get-cached-shell-env.injectable"; +import spawnPtyInjectable from "./shell-session/spawn-pty.injectable"; export function getDiForUnitTesting(opts: { doGeneralOverrides?: boolean } = {}) { const { @@ -158,6 +161,16 @@ export function getDiForUnitTesting(opts: { doGeneralOverrides?: boolean } = {}) di.override(applicationMenuInjectable, () => ({ start: () => {}, stop: () => {} })); di.override(periodicalCheckForUpdatesInjectable, () => ({ start: () => {}, stop: () => {}, started: false })); + di.override(getValidCwdInjectable, () => () => Promise.resolve("/some/valid/cwd")); + di.override(getCachedShellEnvInjectable, (di) => ({ cluster }) => Promise.resolve({ + NO_PROXY: "localhost,127.0.0.1", + TERM_PROGRAM: di.inject(appNameInjectable), + TERM_PROGRAM_VERSION: di.inject(appVersionInjectable), + KUBECONFIG: `/some/proxy/kubeconfig/${cluster.id}`, + PTYPID: "12345", + PTYSHELL: "zsh", + PATH: process.env.PATH, + })); overrideFunctionalInjectables(di, [ getHelmChartInjectable, @@ -175,6 +188,7 @@ export function getDiForUnitTesting(opts: { doGeneralOverrides?: boolean } = {}) readJsonFileInjectable, readFileInjectable, execFileInjectable, + spawnPtyInjectable, ]); // TODO: Remove usages of globally exported appEventBus to get rid of this diff --git a/src/main/shell-session/cached-shell-env.injectable.ts b/src/main/shell-session/cached-shell-env.injectable.ts new file mode 100644 index 0000000000..0d3c134b5f --- /dev/null +++ b/src/main/shell-session/cached-shell-env.injectable.ts @@ -0,0 +1,12 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; + +const cachedShellEnvInjectable = getInjectable({ + id: "cached-shell-env", + instantiate: () => new Map>(), +}); + +export default cachedShellEnvInjectable; diff --git a/src/main/shell-session/ensure-shell-process.injectable.ts b/src/main/shell-session/ensure-shell-process.injectable.ts new file mode 100644 index 0000000000..88af71f86d --- /dev/null +++ b/src/main/shell-session/ensure-shell-process.injectable.ts @@ -0,0 +1,61 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { IPty } from "node-pty"; +import loggerInjectable from "../../common/logger.injectable"; +import { getOrInsertWith } from "../../renderer/utils"; +import shellProcessesInjectable from "./shell-processes.injectable"; +import spawnPtyInjectable from "./spawn-pty.injectable"; + +export interface EnsuredShellProcess { + shellProcess: IPty; + resume: boolean; + cleanup: () => void; +} +export interface EnsureShellProcessArgs { + shell: string; + args: string[]; + env: Record; + cwd: string; + terminalId: string; +} +export type EnsureShellProcess = (args: EnsureShellProcessArgs) => EnsuredShellProcess; + +const ensureShellProcessInjectable = getInjectable({ + id: "ensure-shell-process", + instantiate: (di): EnsureShellProcess => { + const shellProcesses = di.inject(shellProcessesInjectable); + const logger = di.inject(loggerInjectable); + const spawnPty = di.inject(spawnPtyInjectable); + + return ({ shell, args, env, cwd, terminalId } ) => { + const resume = shellProcesses.has(terminalId); + const shellProcess = getOrInsertWith(shellProcesses, terminalId, () => ( + spawnPty(shell, args, { + rows: 30, + cols: 80, + cwd, + env: env as Record, + name: "xterm-256color", + // TODO: Something else is broken here so we need to force the use of winPty on windows + useConpty: false, + }) + )); + + logger.info(`[SHELL-SESSION]: PTY for ${terminalId} is ${resume ? "resumed" : "started"} with PID=${shellProcess.pid}`); + + return { + shellProcess, + resume, + cleanup: () => { + shellProcess.kill(); + shellProcesses.delete(terminalId); + }, + }; + }; + }, +}); + +export default ensureShellProcessInjectable; diff --git a/src/main/shell-session/get-cached-shell-env.injectable.ts b/src/main/shell-session/get-cached-shell-env.injectable.ts new file mode 100644 index 0000000000..8b10c78b8d --- /dev/null +++ b/src/main/shell-session/get-cached-shell-env.injectable.ts @@ -0,0 +1,37 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getInjectable } from "@ogre-tools/injectable"; +import cachedShellEnvInjectable from "./cached-shell-env.injectable"; +import type { GetShellEnv } from "./get-shell-env.injectable"; +import getShellEnvInjectable from "./get-shell-env.injectable"; + +const getCachedShellEnvInjectable = getInjectable({ + id: "get-cached-shell-env", + instantiate: (di): GetShellEnv => { + const cachedShellEnv = di.inject(cachedShellEnvInjectable); + const getShellEnv = di.inject(getShellEnvInjectable); + + return async (args) => { + const { id: clusterId } = args.cluster; + + let env = cachedShellEnv.get(clusterId); + + if (!env) { + env = await getShellEnv(args); + cachedShellEnv.set(clusterId, env); + } else { + // refresh env in the background + getShellEnv(args).then((shellEnv) => { + cachedShellEnv.set(clusterId, shellEnv); + }); + } + + return env; + }; + }, +}); + +export default getCachedShellEnvInjectable; diff --git a/src/main/shell-session/get-shell-env.injectable.ts b/src/main/shell-session/get-shell-env.injectable.ts new file mode 100644 index 0000000000..0c548610e1 --- /dev/null +++ b/src/main/shell-session/get-shell-env.injectable.ts @@ -0,0 +1,86 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getInjectable } from "@ogre-tools/injectable"; +import path from "path"; +import type { Cluster } from "../../common/cluster/cluster"; +import appVersionInjectable from "../../common/get-configuration-file-model/app-version/app-version.injectable"; +import resolvedShellInjectable from "../../common/user-store/resolved-shell.injectable"; +import isWindowsInjectable from "../../common/vars/is-windows.injectable"; +import appNameInjectable from "../app-paths/app-name/app-name.injectable"; +import { clearKubeconfigEnvVars } from "../utils/clear-kube-env-vars"; +import shellEnvInjectable from "../utils/shell-env.injectable"; + +export interface GetShellEnvArgs { + cluster: Cluster; + initialPathEntries: string[]; + kubectlBinDirP: Promise; + kubeconfigPathP: Promise; +} +export type GetShellEnv = (args: GetShellEnvArgs) => Promise>; + +const getShellEnvInjectable = getInjectable({ + id: "get-shell-env", + instantiate: (di): GetShellEnv => { + const isWindows = di.inject(isWindowsInjectable); + const shellEnv = di.inject(shellEnvInjectable); + const resolvedShell = di.inject(resolvedShellInjectable); + const appName = di.inject(appNameInjectable); + const appVersion = di.inject(appVersionInjectable); + + return async ({ cluster, initialPathEntries, kubeconfigPathP, kubectlBinDirP }) => { + const env = clearKubeconfigEnvVars(JSON.parse(JSON.stringify(await shellEnv()))); + const shell = resolvedShell.get(); + const initialPATH = [await kubectlBinDirP, ...initialPathEntries, process.env.PATH].join(path.delimiter); + + delete env.DEBUG; // don't pass DEBUG into shells + + if (isWindows) { + env.SystemRoot = process.env.SystemRoot; + env.PTYSHELL = shell || "powershell.exe"; + env.PATH = initialPATH; + env.LENS_SESSION = "true"; + env.WSLENV = [ + process.env.WSLENV, + "KUBECONFIG/up:LENS_SESSION/u", + ] + .filter(Boolean) + .join(":"); + } else if (shell !== undefined) { + env.PTYSHELL = shell; + env.PATH = initialPATH; + } else { + env.PTYSHELL = ""; // blank runs the system default shell + } + + if (path.basename(env.PTYSHELL) === "zsh") { + env.OLD_ZDOTDIR = env.ZDOTDIR || env.HOME; + env.ZDOTDIR = await kubectlBinDirP; + env.DISABLE_AUTO_UPDATE = "true"; + } + + env.PTYPID = process.pid.toString(); + env.KUBECONFIG = await kubeconfigPathP; + env.TERM_PROGRAM = appName; + env.TERM_PROGRAM_VERSION = appVersion; + + if (cluster.preferences.httpsProxy) { + env.HTTPS_PROXY = cluster.preferences.httpsProxy; + } + + env.NO_PROXY = [ + "localhost", + "127.0.0.1", + env.NO_PROXY, + ] + .filter(Boolean) + .join(); + + return env; + }; + }, +}); + +export default getShellEnvInjectable; diff --git a/src/main/shell-session/get-valid-cwd.injectable.ts b/src/main/shell-session/get-valid-cwd.injectable.ts new file mode 100644 index 0000000000..06b15d3513 --- /dev/null +++ b/src/main/shell-session/get-valid-cwd.injectable.ts @@ -0,0 +1,63 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { homedir } from "os"; +import statInjectable from "../../common/fs/stat.injectable"; +import isMacInjectable from "../../common/vars/is-mac.injectable"; +import isWindowsInjectable from "../../common/vars/is-windows.injectable"; + +export type GetValidCwd = (cwd: string | undefined, env: Record) => Promise; + +const getValidCwdInjectable = getInjectable({ + id: "get-valid-cwd", + instantiate: (di): GetValidCwd => { + const stat = di.inject(statInjectable); + const isWindows = di.inject(isWindowsInjectable); + const isMac = di.inject(isMacInjectable); + + return async (cwd, env) => { + const cwdOptions = [cwd]; + + if (isWindows) { + cwdOptions.push( + env.USERPROFILE, + homedir(), + "C:\\", + ); + } else { + cwdOptions.push( + env.HOME, + homedir(), + ); + + if (isMac) { + cwdOptions.push("/Users"); + } else { + cwdOptions.push("/home"); + } + } + + for (const potentialCwd of cwdOptions) { + if (!potentialCwd) { + continue; + } + + try { + const stats = await stat(potentialCwd); + + if (stats.isDirectory()) { + return potentialCwd; + } + } catch { + // ignore error + } + } + + return "."; // Always valid + }; + }, +}); + +export default getValidCwdInjectable; diff --git a/src/main/shell-session/kill-all-processes.injectable.ts b/src/main/shell-session/kill-all-processes.injectable.ts new file mode 100644 index 0000000000..a8ad21e8c4 --- /dev/null +++ b/src/main/shell-session/kill-all-processes.injectable.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import shellProcessesInjectable from "./shell-processes.injectable"; + +const killAllShellProcessesInjectable = getInjectable({ + id: "kill-all-shell-processes", + instantiate: (di) => { + const shellProcesses = di.inject(shellProcessesInjectable); + + return () => { + for (const shellProcess of shellProcesses.values()) { + try { + process.kill(shellProcess.pid); + } catch { + // ignore error + } + } + + shellProcesses.clear(); + }; + }, +}); + +export default killAllShellProcessesInjectable; diff --git a/src/main/shell-session/local-shell-session/local-shell-session.ts b/src/main/shell-session/local-shell-session/local-shell-session.ts index 6565323bff..ec8cc20da5 100644 --- a/src/main/shell-session/local-shell-session/local-shell-session.ts +++ b/src/main/shell-session/local-shell-session/local-shell-session.ts @@ -6,10 +6,10 @@ import path from "path"; import { UserStore } from "../../../common/user-store"; import type { TerminalShellEnvModify } from "../shell-env-modifier/terminal-shell-env-modify.injectable"; -import type { ShellSessionArgs } from "../shell-session"; +import type { ShellSessionArgs, ShellSessionDependencies } from "../shell-session"; import { ShellSession } from "../shell-session"; -export interface LocalShellSessionDependencies { +export interface LocalShellSessionDependencies extends ShellSessionDependencies { terminalShellEnvModify: TerminalShellEnvModify; readonly baseBundeledBinariesDirectory: string; } @@ -18,7 +18,7 @@ export class LocalShellSession extends ShellSession { ShellType = "shell"; constructor(protected readonly dependencies: LocalShellSessionDependencies, args: ShellSessionArgs) { - super(args); + super(dependencies, args); } protected getPathEntries(): string[] { diff --git a/src/main/shell-session/node-shell-session/node-shell-session.ts b/src/main/shell-session/node-shell-session/node-shell-session.ts index 7bfbb16a70..0979b651a8 100644 --- a/src/main/shell-session/node-shell-session/node-shell-session.ts +++ b/src/main/shell-session/node-shell-session/node-shell-session.ts @@ -6,18 +6,19 @@ import { v4 as uuid } from "uuid"; import { Watch, CoreV1Api } from "@kubernetes/client-node"; import type { KubeConfig } from "@kubernetes/client-node"; -import type { ShellSessionArgs } from "../shell-session"; +import type { ShellSessionArgs, ShellSessionDependencies } from "../shell-session"; import { ShellOpenError, ShellSession } from "../shell-session"; import { get, once } from "lodash"; import { Node, NodeApi } from "../../../common/k8s-api/endpoints"; import { KubeJsonApi } from "../../../common/k8s-api/kube-json-api"; -import logger from "../../logger"; import { TerminalChannels } from "../../../common/terminal/channels"; export interface NodeShellSessionArgs extends ShellSessionArgs { nodeName: string; } +export interface NodeShellSessionDependencies extends ShellSessionDependencies {} + export class NodeShellSession extends ShellSession { ShellType = "node-shell"; @@ -26,8 +27,8 @@ export class NodeShellSession extends ShellSession { protected readonly cwd: string | undefined = undefined; protected readonly nodeName: string; - constructor({ nodeName, ...args }: NodeShellSessionArgs) { - super(args); + constructor(protected readonly dependencies: NodeShellSessionDependencies, { nodeName, ...args }: NodeShellSessionArgs) { + super(dependencies, args); this.nodeName = nodeName; } @@ -39,7 +40,7 @@ export class NodeShellSession extends ShellSession { const cleanup = once(() => { coreApi .deleteNamespacedPod(this.podName, "kube-system") - .catch(error => logger.warn(`[NODE-SHELL]: failed to remove pod shell`, error)); + .catch(error => this.dependencies.logger.warn(`[NODE-SHELL]: failed to remove pod shell`, error)); }); this.websocket.once("close", cleanup); @@ -79,7 +80,7 @@ export class NodeShellSession extends ShellSession { switch (nodeOs) { default: - logger.warn(`[NODE-SHELL-SESSION]: could not determine node OS, falling back with assumption of linux`); + this.dependencies.logger.warn(`[NODE-SHELL-SESSION]: could not determine node OS, falling back with assumption of linux`); // fallthrough case "linux": args.push("sh", "-c", "((clear && bash) || (clear && ash) || (clear && sh))"); @@ -131,7 +132,7 @@ export class NodeShellSession extends ShellSession { } protected waitForRunningPod(kc: KubeConfig): Promise { - logger.debug(`[NODE-SHELL]: waiting for ${this.podName} to be running`); + this.dependencies.logger.debug(`[NODE-SHELL]: waiting for ${this.podName} to be running`); return new Promise((resolve, reject) => { new Watch(kc) @@ -150,19 +151,19 @@ export class NodeShellSession extends ShellSession { }, // done callback is called if the watch terminates normally (err) => { - logger.error(`[NODE-SHELL]: ${this.podName} was not created in time`); + this.dependencies.logger.error(`[NODE-SHELL]: ${this.podName} was not created in time`); reject(err); }, ) .then(req => { setTimeout(() => { - logger.error(`[NODE-SHELL]: aborting wait for ${this.podName}, timing out`); + this.dependencies.logger.error(`[NODE-SHELL]: aborting wait for ${this.podName}, timing out`); req.abort(); reject("Pod creation timed out"); }, 2 * 60 * 1000); // 2 * 60 * 1000 }) .catch(error => { - logger.error(`[NODE-SHELL]: waiting for ${this.podName} failed: ${error}`); + this.dependencies.logger.error(`[NODE-SHELL]: waiting for ${this.podName} failed: ${error}`); reject(error); }); }); diff --git a/src/main/shell-session/shell-processes.injectable.ts b/src/main/shell-session/shell-processes.injectable.ts new file mode 100644 index 0000000000..a27c06b2fa --- /dev/null +++ b/src/main/shell-session/shell-processes.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { IPty } from "node-pty"; + +const shellProcessesInjectable = getInjectable({ + id: "shell-processes", + instantiate: () => new Map(), +}); + +export default shellProcessesInjectable; diff --git a/src/main/shell-session/shell-session.ts b/src/main/shell-session/shell-session.ts index b068f90f84..9c1fa6d13a 100644 --- a/src/main/shell-session/shell-session.ts +++ b/src/main/shell-session/shell-session.ts @@ -6,19 +6,12 @@ import type { Cluster } from "../../common/cluster/cluster"; import type { Kubectl } from "../kubectl/kubectl"; import type WebSocket from "ws"; -import { shellEnv } from "../utils/shell-env"; -import { app } from "electron"; -import { clearKubeconfigEnvVars } from "../utils/clear-kube-env-vars"; -import path from "path"; -import os from "os"; -import { isMac, isWindows } from "../../common/vars"; -import { UserStore } from "../../common/user-store"; -import * as pty from "node-pty"; import { appEventBus } from "../../common/app-event-bus/event-bus"; -import logger from "../logger"; -import { stat } from "fs/promises"; -import { getOrInsertWith } from "../../common/utils"; import { type TerminalMessage, TerminalChannels } from "../../common/terminal/channels"; +import type { EnsureShellProcess } from "./ensure-shell-process.injectable"; +import type { GetValidCwd } from "./get-valid-cwd.injectable"; +import type { GetShellEnv } from "./get-shell-env.injectable"; +import type { Logger } from "../../common/logger"; export class ShellOpenError extends Error { constructor(message: string, options?: ErrorOptions) { @@ -111,27 +104,16 @@ export interface ShellSessionArgs { tabId: string; } +export interface ShellSessionDependencies { + ensureShellProcess: EnsureShellProcess; + getValidCwd: GetValidCwd; + getCachedShellEnv: GetShellEnv; + readonly logger: Logger; +} + export abstract class ShellSession { abstract readonly ShellType: string; - - private static readonly shellEnvs = new Map>(); - private static readonly processes = new Map(); - - /** - * Kill all remaining shell backing processes. Should be called when about to - * quit - */ - public static cleanup(): void { - for (const shellProcess of this.processes.values()) { - try { - process.kill(shellProcess.pid); - } catch { - // ignore error - } - } - - this.processes.clear(); - } + protected abstract readonly cwd: string | undefined; protected running = false; protected readonly kubectlBinDirP: Promise; @@ -141,28 +123,7 @@ export abstract class ShellSession { protected readonly websocket: WebSocket; protected readonly cluster: Cluster; - protected abstract readonly cwd: string | undefined; - - protected ensureShellProcess(shell: string, args: string[], env: Record, cwd: string): { shellProcess: pty.IPty; resume: boolean } { - const resume = ShellSession.processes.has(this.terminalId); - const shellProcess = getOrInsertWith(ShellSession.processes, this.terminalId, () => ( - pty.spawn(shell, args, { - rows: 30, - cols: 80, - cwd, - env: env as Record, - name: "xterm-256color", - // TODO: Something else is broken here so we need to force the use of winPty on windows - useConpty: false, - }) - )); - - logger.info(`[SHELL-SESSION]: PTY for ${this.terminalId} is ${resume ? "resumed" : "started"} with PID=${shellProcess.pid}`); - - return { shellProcess, resume }; - } - - constructor({ cluster, kubectl, tabId, websocket }: ShellSessionArgs) { + constructor(protected readonly dependencies: ShellSessionDependencies, { cluster, kubectl, tabId, websocket }: ShellSessionArgs) { this.cluster = cluster; this.kubectl = kubectl; this.websocket = websocket; @@ -175,50 +136,15 @@ export abstract class ShellSession { this.websocket.send(JSON.stringify(message)); } - protected async getCwd(env: Record): Promise { - const cwdOptions = [this.cwd]; - - if (isWindows) { - cwdOptions.push( - env.USERPROFILE, - os.homedir(), - "C:\\", - ); - } else { - cwdOptions.push( - env.HOME, - os.homedir(), - ); - - if (isMac) { - cwdOptions.push("/Users"); - } else { - cwdOptions.push("/home"); - } - } - - for (const potentialCwd of cwdOptions) { - if (!potentialCwd) { - continue; - } - - try { - const stats = await stat(potentialCwd); - - if (stats.isDirectory()) { - return potentialCwd; - } - } catch { - // ignore error - } - } - - return "."; // Always valid - } - protected async openShellProcess(shell: string, args: string[], env: Record) { - const cwd = await this.getCwd(env); - const { shellProcess, resume } = this.ensureShellProcess(shell, args, env, cwd); + const cwd = await this.dependencies.getValidCwd(this.cwd, env); + const { shellProcess, resume, cleanup } = this.dependencies.ensureShellProcess({ + shell, + args, + env, + cwd, + terminalId: this.terminalId, + }); if (resume) { this.send({ type: TerminalChannels.CONNECTED }); @@ -227,7 +153,7 @@ export abstract class ShellSession { this.running = true; shellProcess.onData(data => this.send({ type: TerminalChannels.STDOUT, data })); shellProcess.onExit(({ exitCode }) => { - logger.info(`[SHELL-SESSION]: shell has exited for ${this.terminalId} closed with exitcode=${exitCode}`); + this.dependencies.logger.info(`[SHELL-SESSION]: shell has exited for ${this.terminalId} closed with exitcode=${exitCode}`); // This might already be false because of the kill() within the websocket.on("close") handler if (this.running) { @@ -245,11 +171,11 @@ export abstract class ShellSession { this.websocket .on("message", (rawData: unknown): void => { if (!this.running) { - return void logger.debug(`[SHELL-SESSION]: received message from ${this.terminalId}, but shellProcess isn't running`); + return void this.dependencies.logger.debug(`[SHELL-SESSION]: received message from ${this.terminalId}, but shellProcess isn't running`); } if (!(rawData instanceof Buffer)) { - return void logger.error(`[SHELL-SESSION]: Received message non-buffer message.`, { rawData }); + return void this.dependencies.logger.error(`[SHELL-SESSION]: Received message non-buffer message.`, { rawData }); } const data = rawData.toString(); @@ -265,18 +191,18 @@ export abstract class ShellSession { shellProcess.resize(message.data.width, message.data.height); break; case TerminalChannels.PING: - logger.silly(`[SHELL-SESSION]: ${this.terminalId} ping!`); + this.dependencies.logger.silly(`[SHELL-SESSION]: ${this.terminalId} ping!`); break; default: - logger.warn(`[SHELL-SESSION]: unknown or unhandleable message type for ${this.terminalId}`, message); + this.dependencies.logger.warn(`[SHELL-SESSION]: unknown or unhandleable message type for ${this.terminalId}`, message); break; } } catch (error) { - logger.error(`[SHELL-SESSION]: failed to handle message for ${this.terminalId}`, error); + this.dependencies.logger.error(`[SHELL-SESSION]: failed to handle message for ${this.terminalId}`, error); } }) .once("close", code => { - logger.info(`[SHELL-SESSION]: websocket for ${this.terminalId} closed with code=${WebSocketCloseEvent[code]}(${code})`, { cluster: this.cluster.getMeta() }); + this.dependencies.logger.info(`[SHELL-SESSION]: websocket for ${this.terminalId} closed with code=${WebSocketCloseEvent[code]}(${code})`, { cluster: this.cluster.getMeta() }); const stopShellSession = this.running && ( @@ -291,11 +217,10 @@ export abstract class ShellSession { this.running = false; try { - logger.info(`[SHELL-SESSION]: Killing shell process (pid=${shellProcess.pid}) for ${this.terminalId}`); - shellProcess.kill(); - ShellSession.processes.delete(this.terminalId); + this.dependencies.logger.info(`[SHELL-SESSION]: Killing shell process (pid=${shellProcess.pid}) for ${this.terminalId}`); + cleanup(); } catch (error) { - logger.warn(`[SHELL-SESSION]: failed to kill shell process (pid=${shellProcess.pid}) for ${this.terminalId}`, error); + this.dependencies.logger.warn(`[SHELL-SESSION]: failed to kill shell process (pid=${shellProcess.pid}) for ${this.terminalId}`, error); } } }); @@ -307,73 +232,13 @@ export abstract class ShellSession { return []; } - protected async getCachedShellEnv() { - const { id: clusterId } = this.cluster; - - let env = ShellSession.shellEnvs.get(clusterId); - - if (!env) { - env = await this.getShellEnv(); - ShellSession.shellEnvs.set(clusterId, env); - } else { - // refresh env in the background - this.getShellEnv().then((shellEnv: any) => { - ShellSession.shellEnvs.set(clusterId, shellEnv); - }); - } - - return env; - } - - protected async getShellEnv() { - const env = clearKubeconfigEnvVars(JSON.parse(JSON.stringify(await shellEnv()))); - const pathStr = [await this.kubectlBinDirP, ...this.getPathEntries(), process.env.PATH].join(path.delimiter); - const shell = UserStore.getInstance().resolvedShell; - - delete env.DEBUG; // don't pass DEBUG into shells - - if (isWindows) { - env.SystemRoot = process.env.SystemRoot; - env.PTYSHELL = shell || "powershell.exe"; - env.PATH = pathStr; - env.LENS_SESSION = "true"; - env.WSLENV = [ - process.env.WSLENV, - "KUBECONFIG/up:LENS_SESSION/u", - ] - .filter(Boolean) - .join(":"); - } else if (shell !== undefined) { - env.PTYSHELL = shell; - env.PATH = pathStr; - } else { - env.PTYSHELL = ""; // blank runs the system default shell - } - - if (path.basename(env.PTYSHELL) === "zsh") { - env.OLD_ZDOTDIR = env.ZDOTDIR || env.HOME; - env.ZDOTDIR = await this.kubectlBinDirP; - env.DISABLE_AUTO_UPDATE = "true"; - } - - env.PTYPID = process.pid.toString(); - env.KUBECONFIG = await this.kubeconfigPathP; - env.TERM_PROGRAM = app.getName(); - env.TERM_PROGRAM_VERSION = app.getVersion(); - - if (this.cluster.preferences.httpsProxy) { - env.HTTPS_PROXY = this.cluster.preferences.httpsProxy; - } - - env.NO_PROXY = [ - "localhost", - "127.0.0.1", - env.NO_PROXY, - ] - .filter(Boolean) - .join(); - - return env; + protected getCachedShellEnv() { + return this.dependencies.getCachedShellEnv({ + cluster: this.cluster, + initialPathEntries: this.getPathEntries(), + kubeconfigPathP: this.kubeconfigPathP, + kubectlBinDirP: this.kubectlBinDirP, + }); } protected exit(code = WebSocketCloseEvent.NormalClosure) { diff --git a/src/main/shell-session/spawn-pty.injectable.ts b/src/main/shell-session/spawn-pty.injectable.ts new file mode 100644 index 0000000000..b106822e5f --- /dev/null +++ b/src/main/shell-session/spawn-pty.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { spawn } from "node-pty"; + +const spawnPtyInjectable = getInjectable({ + id: "spawn-pty", + instantiate: () => spawn, + causesSideEffects: true, +}); + +export default spawnPtyInjectable; diff --git a/src/main/start-main-application/runnables/clean-up-shell-sessions.injectable.ts b/src/main/start-main-application/runnables/clean-up-shell-sessions.injectable.ts index 07066535a1..f0660279ab 100644 --- a/src/main/start-main-application/runnables/clean-up-shell-sessions.injectable.ts +++ b/src/main/start-main-application/runnables/clean-up-shell-sessions.injectable.ts @@ -4,15 +4,13 @@ */ import { getInjectable } from "@ogre-tools/injectable"; import { beforeQuitOfBackEndInjectionToken } from "../runnable-tokens/before-quit-of-back-end-injection-token"; -import { ShellSession } from "../../shell-session/shell-session"; +import killAllShellProcessesInjectable from "../../shell-session/kill-all-processes.injectable"; const cleanUpShellSessionsInjectable = getInjectable({ id: "clean-up-shell-sessions", - instantiate: () => ({ - run: () => { - ShellSession.cleanup(); - }, + instantiate: (di) => ({ + run: di.inject(killAllShellProcessesInjectable), }), injectionToken: beforeQuitOfBackEndInjectionToken, diff --git a/src/main/utils/clear-kube-env-vars.ts b/src/main/utils/clear-kube-env-vars.ts index fab11be4e1..571f06dc61 100644 --- a/src/main/utils/clear-kube-env-vars.ts +++ b/src/main/utils/clear-kube-env-vars.ts @@ -13,7 +13,7 @@ const anyKubeconfig = /^kubeconfig$/i; * before KUBECONFIG and we only set KUBECONFIG. * @param env The current copy of env */ -export function clearKubeconfigEnvVars(env: Record): Record { +export function clearKubeconfigEnvVars(env: Record): Record { return Object.fromEntries( Object.entries(env) .filter(([key]) => anyKubeconfig.exec(key) === null), diff --git a/src/main/utils/shell-env.injectable.ts b/src/main/utils/shell-env.injectable.ts new file mode 100644 index 0000000000..215e8f4560 --- /dev/null +++ b/src/main/utils/shell-env.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { shellEnv } from "./shell-env"; + +const shellEnvInjectable = getInjectable({ + id: "shell-env", + instantiate: () => shellEnv, + causesSideEffects: true, +}); + +export default shellEnvInjectable; diff --git a/src/renderer/components/dock/dock-tabs.tsx b/src/renderer/components/dock/dock-tabs.tsx index 5733d1f161..39ce2b3415 100644 --- a/src/renderer/components/dock/dock-tabs.tsx +++ b/src/renderer/components/dock/dock-tabs.tsx @@ -30,7 +30,7 @@ export const DockTabs = ({ tabs, autoFocus, selectedTab, onChangeTab }: DockTabs return Array.from(elem.current?.querySelectorAll(".Tabs .Tab") ?? []); }; - const renderTab = (tab?: DockTabModel) => { + const renderTab = (tab: DockTabModel | undefined, index: number) => { if (!tab) { return null; } @@ -38,14 +38,37 @@ export const DockTabs = ({ tabs, autoFocus, selectedTab, onChangeTab }: DockTabs switch (tab.kind) { case TabKind.CREATE_RESOURCE: case TabKind.EDIT_RESOURCE: - return ; + return ( + + ); case TabKind.INSTALL_CHART: case TabKind.UPGRADE_CHART: - return ; + return ( + + ); case TabKind.POD_LOGS: - return ; + return ( + + ); case TabKind.TERMINAL: - return ; + return ( + + ); } }; @@ -96,7 +119,11 @@ export const DockTabs = ({ tabs, autoFocus, selectedTab, onChangeTab }: DockTabs scrollable={showScrollbar} className={styles.tabs} > - {tabs.map(tab => {renderTab(tab)})} + {tabs.map((tab, index) => ( + + {renderTab(tab, index)} + + ))} ); diff --git a/src/renderer/components/dock/terminal/allow-transparency.injectable.ts b/src/renderer/components/dock/terminal/allow-transparency.injectable.ts new file mode 100644 index 0000000000..ebe15183a6 --- /dev/null +++ b/src/renderer/components/dock/terminal/allow-transparency.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; + +// This is here so that in tests we can override it to disable a warning because we are using jest-canvas-mock + +const allowTerminalTransparencyInjectable = getInjectable({ + id: "allow-terminal-transparency", + instantiate: () => false, +}); + +export default allowTerminalTransparencyInjectable; diff --git a/src/renderer/components/dock/terminal/create-terminal.injectable.ts b/src/renderer/components/dock/terminal/create-terminal.injectable.ts index a4df656247..4de493a334 100644 --- a/src/renderer/components/dock/terminal/create-terminal.injectable.ts +++ b/src/renderer/components/dock/terminal/create-terminal.injectable.ts @@ -11,6 +11,7 @@ import terminalSpawningPoolInjectable from "./terminal-spawning-pool.injectable" import terminalConfigInjectable from "../../../../common/user-store/terminal-config.injectable"; import terminalCopyOnSelectInjectable from "../../../../common/user-store/terminal-copy-on-select.injectable"; import themeStoreInjectable from "../../../themes/store.injectable"; +import allowTerminalTransparencyInjectable from "./allow-transparency.injectable"; export type CreateTerminal = (tabId: TabId, api: TerminalApi) => Terminal; @@ -22,6 +23,7 @@ const createTerminalInjectable = getInjectable({ terminalConfig: di.inject(terminalConfigInjectable), terminalCopyOnSelect: di.inject(terminalCopyOnSelectInjectable), themeStore: di.inject(themeStoreInjectable), + allowTransparency: di.inject(allowTerminalTransparencyInjectable), }; return (tabId, api) => new Terminal(dependencies, { tabId, api }); diff --git a/src/renderer/components/dock/terminal/terminal.ts b/src/renderer/components/dock/terminal/terminal.ts index 7ea9518437..1a2f7cae8d 100644 --- a/src/renderer/components/dock/terminal/terminal.ts +++ b/src/renderer/components/dock/terminal/terminal.ts @@ -25,6 +25,7 @@ export interface TerminalDependencies { readonly terminalConfig: IComputedValue; readonly terminalCopyOnSelect: IComputedValue; readonly themeStore: ThemeStore; + readonly allowTransparency: boolean; } export interface TerminalArguments { @@ -88,6 +89,7 @@ export class Terminal { cursorStyle: "bar", fontSize: this.fontSize, fontFamily: this.fontFamily, + allowTransparency: dependencies.allowTransparency, }); // enable terminal addons this.xterm.loadAddon(this.fitAddon); @@ -119,6 +121,9 @@ export class Terminal { () => this.api.removeAllListeners(), () => window.removeEventListener("resize", this.onResize), () => this.elem.removeEventListener("contextmenu", this.onContextMenu), + this.xterm.onResize(({ cols, rows }) => { + this.api.sendTerminalSize(cols, rows); + }), ); } @@ -127,21 +132,7 @@ export class Terminal { this.xterm.dispose(); } - fit = () => { - try { - const { cols, rows } = this.fitAddon.proposeDimensions(); - - // attempt to resize/fit terminal when it's not visible in DOM will crash with exception - // see: https://github.com/xtermjs/xterm.js/issues/3118 - if (isNaN(cols) || isNaN(rows)) return; - - this.fitAddon.fit(); - this.api.sendTerminalSize(cols, rows); - } catch (error) { - // see https://github.com/lensapp/lens/issues/1891 - logger.error(`[TERMINAL]: failed to resize terminal to fit`, error); - } - }; + fit = () => this.fitAddon.fit(); fitLazy = debounce(this.fit, 250); diff --git a/src/renderer/components/test-utils/get-application-builder.tsx b/src/renderer/components/test-utils/get-application-builder.tsx index c4957df3b1..464876e135 100644 --- a/src/renderer/components/test-utils/get-application-builder.tsx +++ b/src/renderer/components/test-utils/get-application-builder.tsx @@ -66,16 +66,16 @@ type EnableExtensions = (...extensions: T[]) => void; type DisableExtensions = (...extensions: T[]) => void; export interface ApplicationBuilder { - dis: DiContainers; + readonly dis: DiContainers; setEnvironmentToClusterFrame: () => ApplicationBuilder; - extensions: { - renderer: { + readonly extensions: { + readonly renderer: { enable: EnableExtensions; disable: DisableExtensions; }; - main: { + readonly main: { enable: EnableExtensions; disable: DisableExtensions; }; @@ -89,38 +89,42 @@ export interface ApplicationBuilder { beforeRender: (callback: Callback) => ApplicationBuilder; render: () => Promise; - tray: { + readonly tray: { click: (id: string) => Promise; get: (id: string) => MinimalTrayMenuItem | null; getIconPath: () => string; }; - applicationMenu: { + readonly dock: { + click: (index: number) => void; + }; + + readonly applicationMenu: { click: (path: string) => void; }; - preferences: { + readonly preferences: { close: () => void; navigate: () => void; navigateTo: (route: Route, params: Partial>) => void; - navigation: { + readonly navigation: { click: (id: string) => void; }; }; - helmCharts: { + readonly helmCharts: { navigate: () => void; }; - select: { + readonly select: { openMenu: (id: string) => void; selectOption: (menuId: string, labelText: string) => void; }; } interface DiContainers { - rendererDi: DiContainer; - mainDi: DiContainer; + readonly rendererDi: DiContainer; + readonly mainDi: DiContainer; } interface Environment { @@ -254,6 +258,11 @@ export const getApplicationBuilder = () => { const enableMainExtension = enableExtensionsFor(mainExtensionsState, mainDi); const disableRendererExtension = disableExtensionsFor(rendererExtensionsState, rendererDi); const disableMainExtension = disableExtensionsFor(mainExtensionsState, mainDi); + const dock: ApplicationBuilder["dock"] = { + click: (index) => { + rendered.getByTestId(`dock-tab-${index}`).click(); + }, + }; const builder: ApplicationBuilder = { dis, @@ -293,6 +302,14 @@ export const getApplicationBuilder = () => { }, }, + get dock() { + if (environment === environments.clusterFrame) { + return dock; + } + + throw new Error("cannot use dock in application frame"); + }, + tray: { get: (id: string) => { const lastCall = last(traySetMenuItemsMock.mock.calls); diff --git a/src/renderer/getDiForUnitTesting.tsx b/src/renderer/getDiForUnitTesting.tsx index f70f8938cb..4e9e9cd4c6 100644 --- a/src/renderer/getDiForUnitTesting.tsx +++ b/src/renderer/getDiForUnitTesting.tsx @@ -69,6 +69,7 @@ import kubeObjectDetailsClusterFrameChildComponentInjectable from "./components/ import kubeconfigDialogClusterFrameChildComponentInjectable from "./components/kubeconfig-dialog/kubeconfig-dialog-cluster-frame-child-component.injectable"; import portForwardDialogClusterFrameChildComponentInjectable from "./port-forward/port-forward-dialog-cluster-frame-child-component.injectable"; import setupSystemCaInjectable from "./frames/root-frame/setup-system-ca.injectable"; +import allowTerminalTransparencyInjectable from "./components/dock/terminal/allow-transparency.injectable"; export const getDiForUnitTesting = (opts: { doGeneralOverrides?: boolean } = {}) => { const { @@ -185,7 +186,7 @@ export const getDiForUnitTesting = (opts: { doGeneralOverrides?: boolean } = {}) isTableColumnHidden: () => false, extensionRegistryUrl: { customUrl: "some-custom-url" }, syncKubeconfigEntries: observable.map(), - terminalConfig: { fontSize: 42 }, + terminalConfig: { fontSize: 42, fontFamily: "RobotoMono" }, editorConfiguration: { minimap: {}, tabSize: 42, fontSize: 42 }, } as unknown as UserStore), ); @@ -197,7 +198,7 @@ export const getDiForUnitTesting = (opts: { doGeneralOverrides?: boolean } = {}) overrideFsWithFakes(di); di.override(focusWindowInjectable, () => () => {}); - + di.override(allowTerminalTransparencyInjectable, () => true); di.override(loggerInjectable, () => ({ warn: noop, debug: noop, diff --git a/src/techincal/shell-sessions/__snapshots__/local.test.ts.snap b/src/techincal/shell-sessions/__snapshots__/local.test.ts.snap new file mode 100644 index 0000000000..e396bd7a10 --- /dev/null +++ b/src/techincal/shell-sessions/__snapshots__/local.test.ts.snap @@ -0,0 +1,582 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`local shell session techincal tests renders 1`] = ` + +
+
+
+