From d96918c966e1bd36166f62faac7fb155db188dc2 Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Thu, 13 Oct 2022 07:49:58 -0400 Subject: [PATCH] Fix windows shell not having all environment variables (#6402) * Fix windows shell not having all environment variables Signed-off-by: Sebastian Malton * Fix startup due to buildVersion dependency Signed-off-by: Sebastian Malton * Call cleanup in computeShellEnvironment Signed-off-by: Sebastian Malton Signed-off-by: Sebastian Malton --- .../user-store/resolved-shell.injectable.ts | 13 ++ src/main/__test__/shell-session.test.ts | 6 +- .../local-shell-session.ts | 5 +- .../local-shell-session/open.injectable.ts | 12 +- .../local-shell-session/techincal.test.ts | 90 +++++++++++++ .../node-shell-session/open.injectable.ts | 10 ++ src/main/shell-session/shell-session.ts | 41 +++--- ...pawn-pty.global-override-for-injectable.ts | 10 ++ .../shell-session/spawn-pty.injectable.ts | 21 ++++ .../runnables/setup-shell.injectable.ts | 17 ++- src/main/utils/clear-kube-env-vars.ts | 2 +- src/main/utils/shell-env.ts | 118 ------------------ .../compute-shell-environment.injectable.ts | 62 +++++++++ ...ironment.global-override-for-injectable.ts | 11 ++ ...mpute-unix-shell-environment.injectable.ts | 93 ++++++++++++++ 15 files changed, 367 insertions(+), 144 deletions(-) create mode 100644 src/common/user-store/resolved-shell.injectable.ts create mode 100644 src/main/shell-session/local-shell-session/techincal.test.ts create mode 100644 src/main/shell-session/spawn-pty.global-override-for-injectable.ts create mode 100644 src/main/shell-session/spawn-pty.injectable.ts delete mode 100644 src/main/utils/shell-env.ts create mode 100644 src/main/utils/shell-env/compute-shell-environment.injectable.ts create mode 100644 src/main/utils/shell-env/compute-unix-shell-environment.global-override-for-injectable.ts create mode 100644 src/main/utils/shell-env/compute-unix-shell-environment.injectable.ts 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..98c219feff --- /dev/null +++ b/src/common/user-store/resolved-shell.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 userStoreInjectable from "./user-store.injectable"; + +const resolvedShellInjectable = getInjectable({ + id: "resolved-shell", + instantiate: (di) => di.inject(userStoreInjectable).resolvedShell, +}); + +export default resolvedShellInjectable; diff --git a/src/main/__test__/shell-session.test.ts b/src/main/__test__/shell-session.test.ts index 1fe8915e26..c8c0f1c8df 100644 --- a/src/main/__test__/shell-session.test.ts +++ b/src/main/__test__/shell-session.test.ts @@ -7,14 +7,14 @@ import { clearKubeconfigEnvVars } from "../utils/clear-kube-env-vars"; describe("clearKubeconfigEnvVars tests", () => { it("should not touch non kubeconfig keys", () => { - expect(clearKubeconfigEnvVars({ a: 1 })).toStrictEqual({ a: 1 }); + expect(clearKubeconfigEnvVars({ a: "22" })).toStrictEqual({ a: "22" }); }); it("should remove a single kubeconfig key", () => { - expect(clearKubeconfigEnvVars({ a: 1, kubeconfig: "1" })).toStrictEqual({ a: 1 }); + expect(clearKubeconfigEnvVars({ a: "22", kubeconfig: "1" })).toStrictEqual({ a: "22" }); }); it("should remove a two kubeconfig key", () => { - expect(clearKubeconfigEnvVars({ a: 1, kubeconfig: "1", kUbeconfig: "1" })).toStrictEqual({ a: 1 }); + expect(clearKubeconfigEnvVars({ a: "22", kubeconfig: "1", kUbeconfig: "1" })).toStrictEqual({ a: "22" }); }); }); 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 c13bbeb5a2..49e6dd059f 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 @@ -36,11 +36,8 @@ export class LocalShellSession extends ShellSession { } public async open() { - let env = await this.getCachedShellEnv(); - // extensions can modify the env - env = this.dependencies.modifyTerminalShellEnv(this.cluster.id, env); - + const env = this.dependencies.modifyTerminalShellEnv(this.cluster.id, await this.getCachedShellEnv()); const shell = env.PTYSHELL; if (!shell) { diff --git a/src/main/shell-session/local-shell-session/open.injectable.ts b/src/main/shell-session/local-shell-session/open.injectable.ts index 0187ba97e7..bfb72911ec 100644 --- a/src/main/shell-session/local-shell-session/open.injectable.ts +++ b/src/main/shell-session/local-shell-session/open.injectable.ts @@ -17,6 +17,11 @@ import type WebSocket from "ws"; import getDirnameOfPathInjectable from "../../../common/path/get-dirname.injectable"; import joinPathsInjectable from "../../../common/path/join-paths.injectable"; import getBasenameOfPathInjectable from "../../../common/path/get-basename.injectable"; +import computeShellEnvironmentInjectable from "../../utils/shell-env/compute-shell-environment.injectable"; +import spawnPtyInjectable from "../spawn-pty.injectable"; +import resolvedShellInjectable from "../../../common/user-store/resolved-shell.injectable"; +import appNameInjectable from "../../../common/vars/app-name.injectable"; +import buildVersionInjectable from "../../vars/build-version/build-version.injectable"; export interface OpenLocalShellSessionArgs { websocket: WebSocket; @@ -34,13 +39,18 @@ const openLocalShellSessionInjectable = getInjectable({ const dependencies: LocalShellSessionDependencies = { directoryForBinaries: di.inject(directoryForBinariesInjectable), isMac: di.inject(isMacInjectable), - modifyTerminalShellEnv: di.inject(modifyTerminalShellEnvInjectable), isWindows: di.inject(isWindowsInjectable), logger: di.inject(loggerInjectable), userStore: di.inject(userStoreInjectable), + resolvedShell: di.inject(resolvedShellInjectable), + appName: di.inject(appNameInjectable), + buildVersion: di.inject(buildVersionInjectable), + modifyTerminalShellEnv: di.inject(modifyTerminalShellEnvInjectable), getDirnameOfPath: di.inject(getDirnameOfPathInjectable), joinPaths: di.inject(joinPathsInjectable), getBasenameOfPath: di.inject(getBasenameOfPathInjectable), + computeShellEnvironment: di.inject(computeShellEnvironmentInjectable), + spawnPty: di.inject(spawnPtyInjectable), }; return (args) => { diff --git a/src/main/shell-session/local-shell-session/techincal.test.ts b/src/main/shell-session/local-shell-session/techincal.test.ts new file mode 100644 index 0000000000..fbe2ccaea3 --- /dev/null +++ b/src/main/shell-session/local-shell-session/techincal.test.ts @@ -0,0 +1,90 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { DiContainer } from "@ogre-tools/injectable"; +import { WebSocket } from "ws"; +import directoryForUserDataInjectable from "../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; +import type { Cluster } from "../../../common/cluster/cluster"; +import platformInjectable from "../../../common/vars/platform.injectable"; +import { getDiForUnitTesting } from "../../getDiForUnitTesting"; +import createKubectlInjectable from "../../kubectl/create-kubectl.injectable"; +import type { Kubectl } from "../../kubectl/kubectl"; +import buildVersionInjectable from "../../vars/build-version/build-version.injectable"; +import type { OpenShellSession } from "../create-shell-session.injectable"; +import type { SpawnPty } from "../spawn-pty.injectable"; +import spawnPtyInjectable from "../spawn-pty.injectable"; +import openLocalShellSessionInjectable from "./open.injectable"; + +describe("technical unit tests for local shell sessions", () => { + let di: DiContainer; + + beforeEach(() => { + di = getDiForUnitTesting({ + doGeneralOverrides: true, + }); + + di.override(directoryForUserDataInjectable, () => "/some-directory-for-user-data"); + di.override(buildVersionInjectable, () => ({ + get: () => "1.1.1", + })); + }); + + describe("when on windows", () => { + let openLocalShellSession: OpenShellSession; + let spawnPtyMock: jest.MockedFunction; + + beforeEach(() => { + di.override(platformInjectable, () => "win32"); + + spawnPtyMock = jest.fn(); + di.override(spawnPtyInjectable, () => spawnPtyMock); + + di.override(createKubectlInjectable, () => () => ({ + binDir: async () => "/some-kubectl-binary-dir", + getBundledPath: () => "/some-bundled-kubectl-path", + }) as Partial as Kubectl); + + openLocalShellSession = di.inject(openLocalShellSessionInjectable); + }); + + describe("when opening a local shell session", () => { + it("should pass through all environment variables to shell", async () => { + process.env.MY_TEST_ENV_VAR = "true"; + + spawnPtyMock.mockImplementationOnce((file, args, options) => { + expect(options.env).toMatchObject({ + MY_TEST_ENV_VAR: "true", + }); + + return { + cols: 80, + rows: 40, + pid: 12343, + handleFlowControl: false, + kill: jest.fn(), + onData: jest.fn(), + onExit: jest.fn(), + pause: jest.fn(), + process: "my-pty", + resize: jest.fn(), + resume: jest.fn(), + write: jest.fn(), + on: jest.fn(), + + }; + }); + + await openLocalShellSession({ + cluster: { + getProxyKubeconfigPath: async () => "/some-proxy-kubeconfig", + preferences: {}, + } as Partial as Cluster, + tabId: "my-tab-id", + websocket: new WebSocket(null), + }); + }); + }); + }); +}); diff --git a/src/main/shell-session/node-shell-session/open.injectable.ts b/src/main/shell-session/node-shell-session/open.injectable.ts index 9bc418d812..6080129d6a 100644 --- a/src/main/shell-session/node-shell-session/open.injectable.ts +++ b/src/main/shell-session/node-shell-session/open.injectable.ts @@ -12,6 +12,11 @@ import isMacInjectable from "../../../common/vars/is-mac.injectable"; import isWindowsInjectable from "../../../common/vars/is-windows.injectable"; import loggerInjectable from "../../../common/logger.injectable"; import createKubeJsonApiForClusterInjectable from "../../../common/k8s-api/create-kube-json-api-for-cluster.injectable"; +import computeShellEnvironmentInjectable from "../../utils/shell-env/compute-shell-environment.injectable"; +import spawnPtyInjectable from "../spawn-pty.injectable"; +import resolvedShellInjectable from "../../../common/user-store/resolved-shell.injectable"; +import appNameInjectable from "../../../common/vars/app-name.injectable"; +import buildVersionInjectable from "../../vars/build-version/build-version.injectable"; export interface NodeShellSessionArgs { websocket: WebSocket; @@ -28,7 +33,12 @@ const openNodeShellSessionInjectable = getInjectable({ isMac: di.inject(isMacInjectable), isWindows: di.inject(isWindowsInjectable), logger: di.inject(loggerInjectable), + resolvedShell: di.inject(resolvedShellInjectable), + appName: di.inject(appNameInjectable), + buildVersion: di.inject(buildVersionInjectable), createKubeJsonApiForCluster: di.inject(createKubeJsonApiForClusterInjectable), + computeShellEnvironment: di.inject(computeShellEnvironmentInjectable), + spawnPty: di.inject(spawnPtyInjectable), }; const kubectl = createKubectl(params.cluster.version); const session = new NodeShellSession(dependencies, { kubectl, ...params }); diff --git a/src/main/shell-session/shell-session.ts b/src/main/shell-session/shell-session.ts index 6514b0cf85..e17f533ccb 100644 --- a/src/main/shell-session/shell-session.ts +++ b/src/main/shell-session/shell-session.ts @@ -6,18 +6,18 @@ 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, { userInfo } from "os"; -import { UserStore } from "../../common/user-store"; -import * as pty from "node-pty"; +import type * as pty from "node-pty"; import { appEventBus } from "../../common/app-event-bus/event-bus"; import { stat } from "fs/promises"; import { getOrInsertWith } from "../../common/utils"; import { type TerminalMessage, TerminalChannels } from "../../common/terminal/channels"; import type { Logger } from "../../common/logger"; +import type { ComputeShellEnvironment } from "../utils/shell-env/compute-shell-environment.injectable"; +import type { SpawnPty } from "./spawn-pty.injectable"; +import type { InitializableState } from "../../common/initializable-state/create"; export class ShellOpenError extends Error { constructor(message: string, options?: ErrorOptions) { @@ -107,6 +107,11 @@ export interface ShellSessionDependencies { readonly isWindows: boolean; readonly isMac: boolean; readonly logger: Logger; + readonly resolvedShell: string | undefined; + readonly appName: string; + readonly buildVersion: InitializableState; + computeShellEnvironment: ComputeShellEnvironment; + spawnPty: SpawnPty; } export interface ShellSessionArgs { @@ -148,14 +153,14 @@ export abstract class ShellSession { protected abstract get cwd(): string | undefined; - protected ensureShellProcess(shell: string, args: string[], env: Record, cwd: string): { shellProcess: pty.IPty; resume: boolean } { + protected ensureShellProcess(shell: string, args: string[], env: Partial>, 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, { + this.dependencies.spawnPty(shell, args, { rows: 30, cols: 80, cwd, - env: env as Record, + env, name: "xterm-256color", // TODO: Something else is broken here so we need to force the use of winPty on windows useConpty: false, @@ -331,19 +336,27 @@ export abstract class ShellSession { } protected async getShellEnv() { - const shell = UserStore.getInstance().resolvedShell; - const env = clearKubeconfigEnvVars(JSON.parse(JSON.stringify(await shellEnv(shell || userInfo().shell)))); - const pathStr = [await this.kubectlBinDirP, ...this.getPathEntries(), process.env.PATH].join(path.delimiter); + const shell = this.dependencies.resolvedShell || userInfo().shell; + const result = await this.dependencies.computeShellEnvironment(shell); + const rawEnv = (() => { + if (result.callWasSuccessful) { + return result.response ?? process.env; + } + + return process.env; + })(); + + const env = clearKubeconfigEnvVars(JSON.parse(JSON.stringify(rawEnv))); + const pathStr = [await this.kubectlBinDirP, ...this.getPathEntries(), env.PATH].join(path.delimiter); delete env.DEBUG; // don't pass DEBUG into shells if (this.dependencies.isWindows) { - env.SystemRoot = process.env.SystemRoot; env.PTYSHELL = shell || "powershell.exe"; env.PATH = pathStr; env.LENS_SESSION = "true"; env.WSLENV = [ - process.env.WSLENV, + env.WSLENV, "KUBECONFIG/up:LENS_SESSION/u", ] .filter(Boolean) @@ -363,8 +376,8 @@ export abstract class ShellSession { env.PTYPID = process.pid.toString(); env.KUBECONFIG = await this.kubeconfigPathP; - env.TERM_PROGRAM = app.getName(); - env.TERM_PROGRAM_VERSION = app.getVersion(); + env.TERM_PROGRAM = this.dependencies.appName; + env.TERM_PROGRAM_VERSION = this.dependencies.buildVersion.get(); if (this.cluster.preferences.httpsProxy) { env.HTTPS_PROXY = this.cluster.preferences.httpsProxy; diff --git a/src/main/shell-session/spawn-pty.global-override-for-injectable.ts b/src/main/shell-session/spawn-pty.global-override-for-injectable.ts new file mode 100644 index 0000000000..239f7c5e47 --- /dev/null +++ b/src/main/shell-session/spawn-pty.global-override-for-injectable.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getGlobalOverride } from "../../common/test-utils/get-global-override"; +import spawnPtyInjectable from "./spawn-pty.injectable"; + +export default getGlobalOverride(spawnPtyInjectable, () => () => { + throw new Error("Tried to spawn a PTY without an override"); +}); 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..0639c7307b --- /dev/null +++ b/src/main/shell-session/spawn-pty.injectable.ts @@ -0,0 +1,21 @@ +/** + * 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, IPtyForkOptions, IWindowsPtyForkOptions } from "node-pty"; +import { spawn } from "node-pty"; + +export type WindowsSpawnPtyOptions = Omit & { env?: Partial> }; +export type UnixSpawnPtyOptions = Omit & { env?: Partial> }; +export type SpawnPtyOptions = UnixSpawnPtyOptions | WindowsSpawnPtyOptions; + +export type SpawnPty = (file: string, args: string[], options: SpawnPtyOptions) => IPty; + +const spawnPtyInjectable = getInjectable({ + id: "spawn-pty", + instantiate: () => spawn as SpawnPty, + causesSideEffects: true, +}); + +export default spawnPtyInjectable; diff --git a/src/main/start-main-application/runnables/setup-shell.injectable.ts b/src/main/start-main-application/runnables/setup-shell.injectable.ts index 147d2f24ab..9eb736b2db 100644 --- a/src/main/start-main-application/runnables/setup-shell.injectable.ts +++ b/src/main/start-main-application/runnables/setup-shell.injectable.ts @@ -5,11 +5,11 @@ import { getInjectable } from "@ogre-tools/injectable"; import loggerInjectable from "../../../common/logger.injectable"; import { onLoadOfApplicationInjectionToken } from "../runnable-tokens/on-load-of-application-injection-token"; -import { shellEnv } from "../../utils/shell-env"; import os from "os"; import { unionPATHs } from "../../../common/utils/union-env-path"; import isSnapPackageInjectable from "../../../common/vars/is-snap-package.injectable"; import electronAppInjectable from "../../electron-app/electron-app.injectable"; +import computeShellEnvironmentInjectable from "../../utils/shell-env/compute-shell-environment.injectable"; const setupShellInjectable = getInjectable({ id: "setup-shell", @@ -18,13 +18,24 @@ const setupShellInjectable = getInjectable({ const logger = di.inject(loggerInjectable); const isSnapPackage = di.inject(isSnapPackageInjectable); const electronApp = di.inject(electronAppInjectable); + const computeShellEnvironment = di.inject(computeShellEnvironmentInjectable); return { id: "setup-shell", - run: async () => { + run: async (): Promise => { logger.info("🐚 Syncing shell environment"); - const env = await shellEnv(os.userInfo().shell); + const result = await computeShellEnvironment(os.userInfo().shell); + + if (!result.callWasSuccessful) { + return void logger.error(`[SHELL-SYNC]: ${result.error}`); + } + + const env = result.response; + + if (!env) { + return void logger.debug("[SHELL-SYNC]: nothing to do, env not special in shells"); + } if (!env.LANG) { // the LANG env var expects an underscore instead of electron's dash diff --git a/src/main/utils/clear-kube-env-vars.ts b/src/main/utils/clear-kube-env-vars.ts index fab11be4e1..f678f44807 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: Partial>): Partial> { return Object.fromEntries( Object.entries(env) .filter(([key]) => anyKubeconfig.exec(key) === null), diff --git a/src/main/utils/shell-env.ts b/src/main/utils/shell-env.ts deleted file mode 100644 index 6f4cce0be8..0000000000 --- a/src/main/utils/shell-env.ts +++ /dev/null @@ -1,118 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import { spawn } from "child_process"; -import { randomUUID } from "crypto"; -import { basename } from "path"; -import { isWindows } from "../../common/vars"; -import logger from "../logger"; - -export type EnvironmentVariables = Partial>; - - -async function unixShellEnvironment(shell: string): Promise { - const runAsNode = process.env["ELECTRON_RUN_AS_NODE"]; - const noAttach = process.env["ELECTRON_NO_ATTACH_CONSOLE"]; - const env = { - ...process.env, - ELECTRON_RUN_AS_NODE: "1", - ELECTRON_NO_ATTACH_CONSOLE: "1", - }; - const mark = randomUUID().replace(/-/g, ""); - const regex = new RegExp(`${mark}(.*)${mark}`); - const shellName = basename(shell); - let command: string; - let shellArgs: string[]; - - if (/^pwsh(-preview)?$/.test(shellName)) { - // Older versions of PowerShell removes double quotes sometimes so we use "double single quotes" which is how - // you escape single quotes inside of a single quoted string. - command = `& '${process.execPath}' -p '''${mark}'' + JSON.stringify(process.env) + ''${mark}'''`; - shellArgs = ["-Login", "-Command"]; - } else { - command = `'${process.execPath}' -p '"${mark}" + JSON.stringify(process.env) + "${mark}"'`; - - if (shellName === "tcsh") { - shellArgs = ["-ic"]; - } else { - shellArgs = ["-ilc"]; - } - } - - return new Promise((resolve, reject) => { - const shellProcess = spawn(shell, [...shellArgs, command], { - detached: true, - stdio: ["ignore", "pipe", "pipe"], - env, - }); - const stdout: Buffer[] = []; - - shellProcess.on("error", (err) => reject(err)); - shellProcess.stdout.on("data", b => stdout.push(b)); - shellProcess.on("close", (code, signal) => { - if (code || signal) { - return reject(new Error(`Unexpected return code from spawned shell (code: ${code}, signal: ${signal})`)); - } - - try { - const rawOutput = Buffer.concat(stdout).toString("utf-8"); - const match = regex.exec(rawOutput); - const strippedRawOutput = match ? match[1] : "{}"; - const resolvedEnv = JSON.parse(strippedRawOutput); - - if (runAsNode) { - resolvedEnv["ELECTRON_RUN_AS_NODE"] = runAsNode; - } else { - delete resolvedEnv["ELECTRON_RUN_AS_NODE"]; - } - - if (noAttach) { - resolvedEnv["ELECTRON_NO_ATTACH_CONSOLE"] = noAttach; - } else { - delete resolvedEnv["ELECTRON_NO_ATTACH_CONSOLE"]; - } - - resolve(resolvedEnv); - } catch(err) { - reject(err); - } - }); - }); -} - -let shellSyncFailed = false; - -/** - * Attempts to get the shell environment per the user's existing startup scripts. - * If the environment can't be retrieved after 5 seconds an error message is logged. - * Subsequent calls after such a timeout simply log an error message without trying - * to get the environment, unless forceRetry is true. - * @param shell the shell to get the environment from - * @returns object containing the shell's environment variables. An empty object is - * returned if the call fails. - */ -export async function shellEnv(shell: string) : Promise { - if (isWindows) { - return {}; - } - - if (!shellSyncFailed) { - try { - return await Promise.race([ - unixShellEnvironment(shell), - new Promise((_resolve, reject) => setTimeout(() => { - reject(new Error("Resolving shell environment is taking very long. Please review your shell configuration.")); - }, 30_000)), - ]); - } catch (error) { - logger.error(`shellEnv: ${error}`); - shellSyncFailed = true; - } - } else { - logger.error("shellSync(): Resolving shell environment took too long. Please review your shell configuration."); - } - - return {}; -} diff --git a/src/main/utils/shell-env/compute-shell-environment.injectable.ts b/src/main/utils/shell-env/compute-shell-environment.injectable.ts new file mode 100644 index 0000000000..e3a60fcd03 --- /dev/null +++ b/src/main/utils/shell-env/compute-shell-environment.injectable.ts @@ -0,0 +1,62 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { AsyncResult } from "../../../common/utils/async-result"; +import { getInjectable } from "@ogre-tools/injectable"; +import isWindowsInjectable from "../../../common/vars/is-windows.injectable"; +import { disposer } from "../../../common/utils"; +import computeUnixShellEnvironmentInjectable from "./compute-unix-shell-environment.injectable"; + +export type EnvironmentVariables = Partial>; +export type ComputeShellEnvironment = (shell: string) => Promise>; + +const computeShellEnvironmentInjectable = getInjectable({ + id: "compute-shell-environment", + instantiate: (di): ComputeShellEnvironment => { + const isWindows = di.inject(isWindowsInjectable); + const computeUnixShellEnvironment = di.inject(computeUnixShellEnvironmentInjectable); + + if (isWindows) { + return async () => ({ + callWasSuccessful: true, + response: undefined, + }); + } + + return async (shell) => { + const controller = new AbortController(); + const shellEnv = computeUnixShellEnvironment(shell, { signal: controller.signal }); + const cleanup = disposer(); + + const timeoutHandle = setTimeout(() => controller.abort(), 30_000); + + cleanup.push(() => clearTimeout(timeoutHandle)); + + try { + return { + callWasSuccessful: true, + response: await shellEnv, + }; + } catch (error) { + if (controller.signal.aborted) { + return { + callWasSuccessful: false, + error: "Resolving shell environment is taking very long. Please review your shell configuration.", + }; + } + + return { + callWasSuccessful: false, + error: String(error), + }; + } finally { + cleanup(); + } + }; + }, +}); + +export default computeShellEnvironmentInjectable; + diff --git a/src/main/utils/shell-env/compute-unix-shell-environment.global-override-for-injectable.ts b/src/main/utils/shell-env/compute-unix-shell-environment.global-override-for-injectable.ts new file mode 100644 index 0000000000..aac810aa62 --- /dev/null +++ b/src/main/utils/shell-env/compute-unix-shell-environment.global-override-for-injectable.ts @@ -0,0 +1,11 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getGlobalOverride } from "../../../common/test-utils/get-global-override"; +import computeUnixShellEnvironmentInjectable from "./compute-unix-shell-environment.injectable"; + +export default getGlobalOverride(computeUnixShellEnvironmentInjectable, () => async () => { + throw new Error("Tried to get unix shell env without override"); +}); diff --git a/src/main/utils/shell-env/compute-unix-shell-environment.injectable.ts b/src/main/utils/shell-env/compute-unix-shell-environment.injectable.ts new file mode 100644 index 0000000000..7186e96a88 --- /dev/null +++ b/src/main/utils/shell-env/compute-unix-shell-environment.injectable.ts @@ -0,0 +1,93 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { spawn } from "child_process"; +import { randomUUID } from "crypto"; +import { basename } from "path"; +import type { EnvironmentVariables } from "./compute-shell-environment.injectable"; +import { getInjectable } from "@ogre-tools/injectable"; + +interface UnixShellEnvOptions { + signal?: AbortSignal; +} + +export type ComputeUnixShellEnvironment = (shell: string, opts?: UnixShellEnvOptions) => Promise; + +const computeUnixShellEnvironmentInjectable = getInjectable({ + id: "compute-unix-shell-environment", + instantiate: (): ComputeUnixShellEnvironment => async (shell, opts) => { + const runAsNode = process.env["ELECTRON_RUN_AS_NODE"]; + const noAttach = process.env["ELECTRON_NO_ATTACH_CONSOLE"]; + const env = { + ...process.env, + ELECTRON_RUN_AS_NODE: "1", + ELECTRON_NO_ATTACH_CONSOLE: "1", + }; + const mark = randomUUID().replace(/-/g, ""); + const regex = new RegExp(`${mark}(.*)${mark}`); + const shellName = basename(shell); + let command: string; + let shellArgs: string[]; + + if (/^pwsh(-preview)?$/.test(shellName)) { + // Older versions of PowerShell removes double quotes sometimes so we use "double single quotes" which is how + // you escape single quotes inside of a single quoted string. + command = `& '${process.execPath}' -p '''${mark}'' + JSON.stringify(process.env) + ''${mark}'''`; + shellArgs = ["-Login", "-Command"]; + } else { + command = `'${process.execPath}' -p '"${mark}" + JSON.stringify(process.env) + "${mark}"'`; + + if (shellName === "tcsh") { + shellArgs = ["-ic"]; + } else { + shellArgs = ["-ilc"]; + } + } + + return new Promise((resolve, reject) => { + const shellProcess = spawn(shell, [...shellArgs, command], { + detached: true, + stdio: ["ignore", "pipe", "pipe"], + env, + }); + const stdout: Buffer[] = []; + + opts?.signal?.addEventListener("abort", () => shellProcess.kill()); + + shellProcess.on("error", (err) => reject(err)); + shellProcess.stdout.on("data", b => stdout.push(b)); + shellProcess.on("close", (code, signal) => { + if (code || signal) { + return reject(new Error(`Unexpected return code from spawned shell (code: ${code}, signal: ${signal})`)); + } + + try { + const rawOutput = Buffer.concat(stdout).toString("utf-8"); + const match = regex.exec(rawOutput); + const strippedRawOutput = match ? match[1] : "{}"; + const resolvedEnv = JSON.parse(strippedRawOutput); + + if (runAsNode) { + resolvedEnv["ELECTRON_RUN_AS_NODE"] = runAsNode; + } else { + delete resolvedEnv["ELECTRON_RUN_AS_NODE"]; + } + + if (noAttach) { + resolvedEnv["ELECTRON_NO_ATTACH_CONSOLE"] = noAttach; + } else { + delete resolvedEnv["ELECTRON_NO_ATTACH_CONSOLE"]; + } + + resolve(resolvedEnv); + } catch(err) { + reject(err); + } + }); + }); + }, + causesSideEffects: true, +}); + +export default computeUnixShellEnvironmentInjectable;