From f48e6dc049ba37aa24531b5eac9616ece90693bb Mon Sep 17 00:00:00 2001 From: Priya Patel Date: Mon, 30 Mar 2026 10:08:55 -0700 Subject: [PATCH] Refactor: Load terminal using web contents view (#3190) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: 🤖 create terminal preload * chore: 🤖 created terminal-manager service and updated handlers * chore: 🤖 adds terminal view and bundling script * chore: 🤖 addede terminal service, updated tabs component * chore: 🤖 hides terminal on signout modal * test: 💍 added terminal service tests * chore: 🤖 removed debug file * chore: 🤖 adds windows event handler * docs: ✏️ adds jsdoc for terminal view * chore: 🤖 removed xterm dependency from desktop package.json * chore: 🤖 updated sessionId check * chore: 🤖 fixed zoom sync with main window * chore: 🤖 adds CSP for terminal view * chore: 🤖 hides terminal on error * chore: 🤖 cleamup listener, fixed failing tests * chore: 🤖 fixed terminal view cleanup * chore: 🤖 addressed feedback * fix: 🐛 adds null check for destroy, hdie terminal view * fix: 🐛 fixed terminal view on logout/app quit modal * fix: 🐛 fixed hidding modal on app quit * chore: 🤖 addressed feedback * chore: 🤖 hides terminal view when sidenav is expanded * chore: 🤖 removed canDisplayExistingTerminal --- pnpm-lock.yaml | 32 -- .../app/components/session/tabs/index.hbs | 2 +- .../app/components/session/tabs/index.js | 136 ++---- ui/desktop/app/controllers/application.js | 35 ++ .../scopes/scope/projects/sessions/index.js | 22 +- ui/desktop/app/routes/application.js | 3 + ui/desktop/app/routes/index.js | 4 + ui/desktop/app/services/terminal.js | 102 +++++ ui/desktop/app/templates/application.hbs | 2 +- ui/desktop/electron-app/.gitignore | 3 + .../config/terminal-view.config.js | 44 ++ ui/desktop/electron-app/package.json | 12 +- ui/desktop/electron-app/pnpm-lock.yaml | 393 ++++++++++++++++++ ui/desktop/electron-app/src/index.js | 13 + ui/desktop/electron-app/src/ipc/handlers.js | 77 ++-- ui/desktop/electron-app/src/preload.js | 15 +- .../src/services/terminal-manager.js | 143 +++++++ .../electron-app/src/terminal-preload.js | 41 ++ .../src/terminal-view/terminal.css | 18 + .../src/terminal-view/terminal.html | 18 + .../src/terminal-view/terminal.js | 81 ++++ ui/desktop/ember-cli-build.js | 2 - ui/desktop/eslint.config.js | 2 + ui/desktop/package.json | 3 - .../tests/unit/services/terminal-test.js | 108 +++++ 25 files changed, 1097 insertions(+), 214 deletions(-) create mode 100644 ui/desktop/app/services/terminal.js create mode 100644 ui/desktop/electron-app/config/terminal-view.config.js create mode 100644 ui/desktop/electron-app/src/services/terminal-manager.js create mode 100644 ui/desktop/electron-app/src/terminal-preload.js create mode 100644 ui/desktop/electron-app/src/terminal-view/terminal.css create mode 100644 ui/desktop/electron-app/src/terminal-view/terminal.html create mode 100644 ui/desktop/electron-app/src/terminal-view/terminal.js create mode 100644 ui/desktop/tests/unit/services/terminal-test.js diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4a6ceeb5d..97a661186 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1015,15 +1015,6 @@ importers: '@ember/string': specifier: ^4.0.0 version: 4.0.1 - '@xterm/addon-canvas': - specifier: ^0.7.0 - version: 0.7.0(@xterm/xterm@5.5.0) - '@xterm/addon-fit': - specifier: ^0.10.0 - version: 0.10.0(@xterm/xterm@5.5.0) - '@xterm/xterm': - specifier: ^5.5.0 - version: 5.5.0 ember-a11y-refocus: specifier: ^5.0.0 version: 5.1.0(@babel/core@7.27.1)(ember-source@5.12.0(@glimmer/component@2.0.0)(@glint/template@1.5.2)(rsvp@4.8.5)(webpack@5.105.4(esbuild@0.25.9))) @@ -3208,19 +3199,6 @@ packages: resolution: {integrity: sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==} engines: {node: '>=10.0.0'} - '@xterm/addon-canvas@0.7.0': - resolution: {integrity: sha512-LF5LYcfvefJuJ7QotNRdRSPc9YASAVDeoT5uyXS/nZshZXjYplGXRECBGiznwvhNL2I8bq1Lf5MzRwstsYQ2Iw==} - peerDependencies: - '@xterm/xterm': ^5.0.0 - - '@xterm/addon-fit@0.10.0': - resolution: {integrity: sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==} - peerDependencies: - '@xterm/xterm': ^5.0.0 - - '@xterm/xterm@5.5.0': - resolution: {integrity: sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==} - '@xtuc/ieee754@1.2.0': resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} @@ -12083,16 +12061,6 @@ snapshots: '@xmldom/xmldom@0.8.10': {} - '@xterm/addon-canvas@0.7.0(@xterm/xterm@5.5.0)': - dependencies: - '@xterm/xterm': 5.5.0 - - '@xterm/addon-fit@0.10.0(@xterm/xterm@5.5.0)': - dependencies: - '@xterm/xterm': 5.5.0 - - '@xterm/xterm@5.5.0': {} - '@xtuc/ieee754@1.2.0': {} '@xtuc/long@4.2.2': {} diff --git a/ui/desktop/app/components/session/tabs/index.hbs b/ui/desktop/app/components/session/tabs/index.hbs index daed56ef5..dbd35d178 100644 --- a/ui/desktop/app/components/session/tabs/index.hbs +++ b/ui/desktop/app/components/session/tabs/index.hbs @@ -17,7 +17,7 @@ {{else}} {{! Non-RDP: Show tabbed interface }} - {{t 'titles.details'}} + {{t 'titles.details'}} {{t 'resources.session.shell'}} diff --git a/ui/desktop/app/components/session/tabs/index.js b/ui/desktop/app/components/session/tabs/index.js index ba06148c9..b5f13c987 100644 --- a/ui/desktop/app/components/session/tabs/index.js +++ b/ui/desktop/app/components/session/tabs/index.js @@ -5,132 +5,52 @@ import Component from '@glimmer/component'; import { action } from '@ember/object'; -import { Terminal } from '@xterm/xterm'; -import { FitAddon } from '@xterm/addon-fit'; -import { CanvasAddon } from '@xterm/addon-canvas'; -import { v4 as uuidv4 } from 'uuid'; -import debounce from 'lodash/debounce'; - -const terminalOptions = { - cursorBlink: true, -}; - -// This dynamically sets the terminal container's height inline so we have an accurate -// height for the terminal container. This is necessary because we don't have an -// intrinsic height from our parent containers and we don't have any content yet from -// the terminal to have a usable height for the container. This means that calling `.fit()` -// doesn't get an accurate representation of what the container size can actually be -const calculateTerminalContainerHeight = (termContainer) => { - const viewportHeight = window.innerHeight; - // Calculate the height by getting the viewport height, subtracting what - // the top offset from the container is, and leaving a small padding to - // also prevent any overflow scrolling on the container - const height = viewportHeight - termContainer.offsetTop - 24; - termContainer.style.height = `${height}px`; -}; +import { service } from '@ember/service'; export default class SessionTerminalTabsComponent extends Component { - // =attributes + // =services - id; - terminal; - removeTerminalListener; + @service terminal; // =actions @action async openTerminal() { - // Don't re-initialize the terminal if it's already been opened - if (this.terminal) { + this.terminal.setTerminalTabActive(true); + + // If the terminal view is already created, just display the existing one. + if (this.terminal.isTerminalViewCreated) { + this.terminal.displayTerminalView(); return; } - const { isWindows } = await window.desktop.system.checkOS(); - const xterm = new Terminal(terminalOptions); - this.terminal = xterm; - const fitAddon = new FitAddon(); - const termContainer = document.getElementById(`terminal-container`); - calculateTerminalContainerHeight(termContainer); - - /** - * Custom key event handler for windows to achieve Ctrl+C as on the other OS's - */ - if (isWindows) { - xterm.attachCustomKeyEventHandler(async (arg) => { - if (arg.ctrlKey && arg.code === 'KeyC' && arg.type === 'keydown') { - const selection = xterm.getSelection(); - if (selection) { - await navigator.clipboard.writeText(selection); - return false; - } - } - return true; - }); - } - - xterm.loadAddon(fitAddon); - xterm.loadAddon(new CanvasAddon()); - xterm.open(termContainer); - fitAddon.fit(); - xterm.focus(); - - // Generate a UUID to have the terminal handlers be unique - this.id = uuidv4(); - this.#setupTerminal(fitAddon, xterm, termContainer); - + // If the terminal view has not been created yet, we create it. const isSSHCommandAvailable = await window.desktop.system.checkCommand('ssh'); - const { model } = this.args; + const { started_desktop_client, target, id } = this.args.model; - const { proxy_address, proxy_port, started_desktop_client, target } = model; + const autoSSH = Boolean( + target?.isSSH && started_desktop_client && isSSHCommandAvailable, + ); - // Only send the command in certain scenarios: - // 1. Only for SSH targets - // 2. Don't connect if the session wasn't initiated in the desktop client - // which means we won't have proxy information - // 3. Only connect if the user has an SSH client available in their path - if (target?.isSSH && started_desktop_client && isSSHCommandAvailable) { - // Send an SSH command immediately - window.terminal.send( - `ssh ${proxy_address} -p ${proxy_port} -o NoHostAuthenticationForLocalhost=yes\r`, - this.id, - ); - } + const payload = { + id, + autoSSH, + }; + + this.terminal.createTerminalView(payload); + } + + /** + * When the details tab is clicked, we want to hide the terminal view + */ + @action + openDetails() { + this.terminal.setTerminalTabActive(false); } willDestroy() { super.willDestroy(...arguments); - - // Kill terminal and clean up event listeners - if (this.terminal) { - this.terminal.dispose(); - } - window.terminal?.remove(this.id); - window.onresize = null; - this.removeTerminalListener?.(); - } - - #setupTerminal(fitAddon, xterm, termContainer) { - // Terminal is exposed by contextBridge within the preload script - window.terminal.create({ id: this.id, cols: xterm.cols, rows: xterm.rows }); - xterm.onData((data) => window.terminal.send(data, this.id)); - - // Save the handler to cleanup the listener on the renderer process later - this.removeTerminalListener = window.terminal.receive((value) => { - xterm.write(value); - }, this.id); - - // Handle resizing terminal windows. We debounce the resizing as we don't want - // to resize xterm and the pty process before the previous one has finished - const debouncedFit = debounce(() => { - calculateTerminalContainerHeight(termContainer); - fitAddon.fit(); - }, 150); - window.onresize = () => { - debouncedFit(); - }; - xterm.onResize((size) => { - window.terminal.resize(size, this.id); - }); + this.terminal.cleanup(); } } diff --git a/ui/desktop/app/controllers/application.js b/ui/desktop/app/controllers/application.js index 4bf6622a4..78c534fd3 100644 --- a/ui/desktop/app/controllers/application.js +++ b/ui/desktop/app/controllers/application.js @@ -21,6 +21,7 @@ export default class ApplicationController extends Controller { @service router; @service scope; @service intl; + @service terminal; // =attributes @@ -35,6 +36,7 @@ export default class ApplicationController extends Controller { // Setup removeOnAppQuitListener to destroy the listener afterwards this.removeOnAppQuitListener = this.window.desktop?.app.onAppQuit(() => { this.isAppQuitting = true; + this.hideTerminalViewForModal(); }); } @@ -91,6 +93,7 @@ export default class ApplicationController extends Controller { await window.desktop.session.hasRunningSessions(); if (hasRunningSessions) { this.isLoggingOut = true; + this.hideTerminalViewForModal(); } else { this.session.invalidate(); } @@ -151,6 +154,9 @@ export default class ApplicationController extends Controller { cancel() { this.isLoggingOut = false; this.isAppQuitting = false; + if (this.terminal.isTerminalTabActive) { + this.terminal.displayTerminalView(); + } } willDestroy() { @@ -182,4 +188,33 @@ export default class ApplicationController extends Controller { } return defaultValidator(transition); } + + /** + * Hides terminal if open and on terminal tab of Session details page for logout/app quit modal + */ + hideTerminalViewForModal() { + if (this.terminal.isTerminalTabActive) { + this.terminal.hideTerminalView(); + } + } + + /** + * Handles side nav toggle to hide/show terminal based on side nav state + * @param {boolean} isMinimized - Whether the side nav is minimized i.e. collapsed + */ + @action + handleSideNavToggle(isMinimized) { + this.terminal.setSideNavMinimized(isMinimized); + + // update terminal view if we're on the terminal tab + if (this.terminal.isTerminalTabActive) { + if (isMinimized) { + // Side nav is collapsed, display terminal view + this.terminal.displayTerminalView(); + } else { + // Side nav is expanded, hide terminal view + this.terminal.hideTerminalView(); + } + } + } } diff --git a/ui/desktop/app/controllers/scopes/scope/projects/sessions/index.js b/ui/desktop/app/controllers/scopes/scope/projects/sessions/index.js index b46d827bc..dd855916d 100644 --- a/ui/desktop/app/controllers/scopes/scope/projects/sessions/index.js +++ b/ui/desktop/app/controllers/scopes/scope/projects/sessions/index.js @@ -25,6 +25,7 @@ export default class ScopesScopeProjectsSessionsIndexController extends Controll @service session; @service router; @service abilities; + @service terminal; // =attributes @@ -154,14 +155,19 @@ export default class ScopesScopeProjectsSessionsIndexController extends Controll }); } - await updatedSession.cancelSession(); - - await window.desktop.session.stopSession({ session_id: session.id }); - if ( - this.router.currentRoute.name === - 'scopes.scope.projects.sessions.session.index' - ) { - this.router.replaceWith('scopes.scope.projects.targets.index'); + try { + await updatedSession.cancelSession(); + await window.desktop.session.stopSession({ session_id: session.id }); + if ( + this.router.currentRoute.name === + 'scopes.scope.projects.sessions.session.index' + ) { + this.router.replaceWith('scopes.scope.projects.targets.index'); + } + } catch (e) { + // hide terminal view + this.terminal.hideTerminalView(); + throw e; } } diff --git a/ui/desktop/app/routes/application.js b/ui/desktop/app/routes/application.js index 5bd9c8bf6..191ba5fbf 100644 --- a/ui/desktop/app/routes/application.js +++ b/ui/desktop/app/routes/application.js @@ -16,6 +16,7 @@ export default class ApplicationRoute extends Route { @service clusterUrl; @service intl; @service rdp; + @service terminal; // =attributes @@ -83,6 +84,8 @@ export default class ApplicationRoute extends Route { @action error(e) { const isUnauthenticated = A(e?.errors)?.[0]?.isUnauthenticated; + // clean up terminal view for all errors + this.terminal.cleanup(); if (isUnauthenticated) { this.session.invalidate(); return false; diff --git a/ui/desktop/app/routes/index.js b/ui/desktop/app/routes/index.js index f8eedc325..16a7af83e 100644 --- a/ui/desktop/app/routes/index.js +++ b/ui/desktop/app/routes/index.js @@ -11,6 +11,7 @@ export default class IndexRoute extends Route { @service clusterUrl; @service router; + @service terminal; // =methods @@ -18,6 +19,9 @@ export default class IndexRoute extends Route { * If no clusterUrl is specified yet, redirects to cluster-url, otherwise scopes. */ redirect() { + // cleanup terminal view when navigating to index route - on window reload + this.terminal.cleanup(); + const rendererClusterUrl = this.clusterUrl.rendererClusterUrl; if (!rendererClusterUrl) { this.router.replaceWith('cluster-url'); diff --git a/ui/desktop/app/services/terminal.js b/ui/desktop/app/services/terminal.js new file mode 100644 index 000000000..e1add4577 --- /dev/null +++ b/ui/desktop/app/services/terminal.js @@ -0,0 +1,102 @@ +/** + * Copyright IBM Corp. 2021, 2026 + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Service from '@ember/service'; +import { tracked } from '@glimmer/tracking'; +import debounce from 'lodash/debounce'; + +// This dynamically sets the terminal container's height inline so we have an accurate +// height for the terminal container. This is necessary because we don't have an +// intrinsic height from our parent containers and we don't have any content yet from +// the terminal to have a usable height for the container. This means that calling `.fit()` +// doesn't get an accurate representation of what the container size can actually be +const calculateTerminalContainerHeight = (termContainer) => { + const rect = termContainer.getBoundingClientRect(); + const viewportHeight = window.innerHeight; + // Calculate the height by getting the viewport height, subtracting what + // the top offset from the container is, and leaving a small padding to + // also prevent any overflow scrolling on the container + const height = viewportHeight - termContainer.offsetTop - 24; + return { + x: rect.left, + y: rect.top, + width: rect.width, + height: height, + }; +}; + +export default class TerminalService extends Service { + // =properties + + @tracked isTerminalViewCreated = false; + + /** + * Helps track whether the terminal tab is active so we can determine whether to display/hide the existing terminal view + */ + @tracked isTerminalTabActive = false; + + /** + * Tracks whether the app side nav is minimized i.e. collapsed so we can determine whether to display/hide the existing terminal view + */ + @tracked isSideNavMinimized = true; + + get terminalPosition() { + const termContainer = document.getElementById('terminal-container'); + return calculateTerminalContainerHeight(termContainer); + } + + // =methods + + setTerminalTabActive(isActive) { + this.isTerminalTabActive = isActive; + + if (!isActive) { + this.hideTerminalView(); + } + } + + setSideNavMinimized(isMinimized) { + this.isSideNavMinimized = isMinimized; + } + + /** + * Displays the existing terminal view if it has already been created and is not currently open. + */ + displayTerminalView() { + window.webContentView.positionTerminalView(this.terminalPosition); + } + + /** + * Hides the terminal view. We do this instead of destroying the view because we want to preserve the state of the terminal session. + */ + hideTerminalView() { + window.webContentView?.hideTerminalView(); + } + + createTerminalView(payload) { + const updatedPayload = { + ...payload, + position: this.terminalPosition, + }; + window.webContentView.createTerminalView(updatedPayload); + this.isTerminalViewCreated = true; + // Handle resizing terminal windows. We debounce the resizing as we don't want + // to resize xterm and the pty process before the previous one has finished + const debouncedFit = debounce(() => { + window.webContentView.positionTerminalView(this.terminalPosition); + }, 150); + window.onresize = () => { + debouncedFit(); + }; + } + + cleanup() { + window.webContentView?.destroyTerminalView(); + window.onresize = null; + this.isTerminalViewCreated = false; + this.isTerminalTabActive = false; + this.isSideNavMinimized = false; + } +} diff --git a/ui/desktop/app/templates/application.hbs b/ui/desktop/app/templates/application.hbs index baac989b3..ad5daf4c7 100644 --- a/ui/desktop/app/templates/application.hbs +++ b/ui/desktop/app/templates/application.hbs @@ -133,7 +133,7 @@ - + diff --git a/ui/desktop/electron-app/.gitignore b/ui/desktop/electron-app/.gitignore index 3c6212f76..7dc09c64f 100644 --- a/ui/desktop/electron-app/.gitignore +++ b/ui/desktop/electron-app/.gitignore @@ -90,6 +90,9 @@ config/config.js ember-dist/ ember-test/ +# Terminal view build output +terminal-view-dist/ + # CLI /cli/ diff --git a/ui/desktop/electron-app/config/terminal-view.config.js b/ui/desktop/electron-app/config/terminal-view.config.js new file mode 100644 index 000000000..4c70c0b6f --- /dev/null +++ b/ui/desktop/electron-app/config/terminal-view.config.js @@ -0,0 +1,44 @@ +/** + * Copyright IBM Corp. 2021, 2026 + * SPDX-License-Identifier: BUSL-1.1 + */ + +const esbuild = require('esbuild'); +const fs = require('fs'); +const path = require('path'); + +const projectRoot = path.resolve(__dirname, '..'); +const srcDir = path.join(projectRoot, 'src', 'terminal-view'); +const outDir = path.join(projectRoot, 'terminal-view-dist'); + +const isProduction = process.env.NODE_ENV === 'production'; + +const buildTerminalView = () => { + const entryPoint = path.join(srcDir, 'terminal.js'); + const inputHTMLPath = path.join(srcDir, 'terminal.html'); + const outputHTMLPath = path.join(outDir, 'terminal.html'); + const outputJSPath = path.join(outDir, 'terminal.js'); + + fs.promises.mkdir(outDir, { recursive: true }); + + esbuild.build({ + entryPoints: [entryPoint], + bundle: true, + outfile: outputJSPath, + platform: 'browser', + format: 'esm', + minify: isProduction, + sourcemap: !isProduction, + loader: { + '.css': 'css', + }, + }); + + fs.promises.copyFile(inputHTMLPath, outputHTMLPath); +}; + +module.exports = { + setup: () => { + buildTerminalView(); + }, +}; diff --git a/ui/desktop/electron-app/package.json b/ui/desktop/electron-app/package.json index 9b278b608..46e33a315 100644 --- a/ui/desktop/electron-app/package.json +++ b/ui/desktop/electron-app/package.json @@ -10,7 +10,8 @@ "scripts": { "build:cli": "node -e \"require('./config/cli.js').setup()\"", "build:config": "node -e \"require('./config/desktop.js').setup()\"", - "build:all": "pnpm build:cli && pnpm build:config", + "build:terminal-view": "node -e \"require('./config/terminal-view.config.js').setup()\"", + "build:all": "pnpm build:cli && pnpm build:config && pnpm build:terminal-view", "start": "electron-forge start", "package": "electron-forge package", "make": "pnpm build:all && electron-forge make", @@ -22,6 +23,9 @@ "forge": "config/forge.config.js" }, "dependencies": { + "@xterm/addon-canvas": "^0.7.0", + "@xterm/addon-fit": "^0.10.0", + "@xterm/xterm": "^5.5.0", "electron-devtools-installer": "^3.2.0", "electron-is-dev": "^2.0.0", "electron-log": "^5.1.2", @@ -41,6 +45,7 @@ "@electron-forge/maker-squirrel": "^7.7.0", "@electron-forge/maker-zip": "^7.7.0", "electron": "38.2.1", + "esbuild": "^0.25.4", "unzipper": "^0.11.5" }, "pnpm": { @@ -51,9 +56,10 @@ "onlyBuiltDependencies": [ "electron", "electron-winstaller", + "esbuild", "fs-xattr", - "node-pty", - "macos-alias" + "macos-alias", + "node-pty" ] } } diff --git a/ui/desktop/electron-app/pnpm-lock.yaml b/ui/desktop/electron-app/pnpm-lock.yaml index 3e023fb2f..82979c573 100644 --- a/ui/desktop/electron-app/pnpm-lock.yaml +++ b/ui/desktop/electron-app/pnpm-lock.yaml @@ -10,6 +10,15 @@ overrides: importers: .: dependencies: + '@xterm/addon-canvas': + specifier: ^0.7.0 + version: 0.7.0(@xterm/xterm@5.5.0) + '@xterm/addon-fit': + specifier: ^0.10.0 + version: 0.10.0(@xterm/xterm@5.5.0) + '@xterm/xterm': + specifier: ^5.5.0 + version: 5.5.0 electron-devtools-installer: specifier: ^3.2.0 version: 3.2.1 @@ -62,6 +71,9 @@ importers: electron: specifier: 38.2.1 version: 38.2.1 + esbuild: + specifier: ^0.25.4 + version: 0.25.12 unzipper: specifier: ^0.11.5 version: 0.11.6 @@ -264,6 +276,240 @@ packages: engines: { node: '>=14.14' } hasBin: true + '@esbuild/aix-ppc64@0.25.12': + resolution: + { + integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==, + } + engines: { node: '>=18' } + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: + { + integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==, + } + engines: { node: '>=18' } + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: + { + integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==, + } + engines: { node: '>=18' } + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: + { + integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==, + } + engines: { node: '>=18' } + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: + { + integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==, + } + engines: { node: '>=18' } + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: + { + integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==, + } + engines: { node: '>=18' } + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: + { + integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==, + } + engines: { node: '>=18' } + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: + { + integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==, + } + engines: { node: '>=18' } + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: + { + integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==, + } + engines: { node: '>=18' } + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: + { + integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==, + } + engines: { node: '>=18' } + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: + { + integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==, + } + engines: { node: '>=18' } + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: + { + integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==, + } + engines: { node: '>=18' } + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: + { + integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==, + } + engines: { node: '>=18' } + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: + { + integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==, + } + engines: { node: '>=18' } + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: + { + integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==, + } + engines: { node: '>=18' } + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: + { + integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==, + } + engines: { node: '>=18' } + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: + { + integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==, + } + engines: { node: '>=18' } + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: + { + integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==, + } + engines: { node: '>=18' } + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: + { + integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==, + } + engines: { node: '>=18' } + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: + { + integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==, + } + engines: { node: '>=18' } + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: + { + integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==, + } + engines: { node: '>=18' } + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: + { + integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==, + } + engines: { node: '>=18' } + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: + { + integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==, + } + engines: { node: '>=18' } + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: + { + integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==, + } + engines: { node: '>=18' } + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: + { + integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==, + } + engines: { node: '>=18' } + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: + { + integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==, + } + engines: { node: '>=18' } + cpu: [x64] + os: [win32] + '@gar/promisify@1.1.3': resolution: { @@ -403,6 +649,28 @@ packages: } engines: { node: '>=10.0.0' } + '@xterm/addon-canvas@0.7.0': + resolution: + { + integrity: sha512-LF5LYcfvefJuJ7QotNRdRSPc9YASAVDeoT5uyXS/nZshZXjYplGXRECBGiznwvhNL2I8bq1Lf5MzRwstsYQ2Iw==, + } + peerDependencies: + '@xterm/xterm': ^5.0.0 + + '@xterm/addon-fit@0.10.0': + resolution: + { + integrity: sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==, + } + peerDependencies: + '@xterm/xterm': ^5.0.0 + + '@xterm/xterm@5.5.0': + resolution: + { + integrity: sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==, + } + abbrev@1.1.1: resolution: { @@ -1139,6 +1407,14 @@ packages: integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==, } + esbuild@0.25.12: + resolution: + { + integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==, + } + engines: { node: '>=18' } + hasBin: true + escalade@3.2.0: resolution: { @@ -3610,6 +3886,84 @@ snapshots: transitivePeerDependencies: - supports-color + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + '@gar/promisify@1.1.3': {} '@isaacs/fs-minipass@4.0.1': @@ -3693,6 +4047,16 @@ snapshots: '@xmldom/xmldom@0.8.10': {} + '@xterm/addon-canvas@0.7.0(@xterm/xterm@5.5.0)': + dependencies: + '@xterm/xterm': 5.5.0 + + '@xterm/addon-fit@0.10.0(@xterm/xterm@5.5.0)': + dependencies: + '@xterm/xterm': 5.5.0 + + '@xterm/xterm@5.5.0': {} + abbrev@1.1.1: {} agent-base@6.0.2: @@ -4163,6 +4527,35 @@ snapshots: es6-error@4.1.1: optional: true + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + escalade@3.2.0: {} escape-string-regexp@1.0.5: {} diff --git a/ui/desktop/electron-app/src/index.js b/ui/desktop/electron-app/src/index.js index 9b247d8ef..e73f5c5a8 100644 --- a/ui/desktop/electron-app/src/index.js +++ b/ui/desktop/electron-app/src/index.js @@ -23,6 +23,7 @@ const sessionManager = require('./services/session-manager.js'); const cacheDaemonManager = require('./services/cache-daemon-manager'); const rdpClientManager = require('./services/rdp-client-manager'); const store = require('./services/electron-store-manager'); +const terminalManager = require('./services/terminal-manager'); const menu = require('./config/menu.js'); const appUpdater = require('./helpers/app-updater.js'); @@ -223,6 +224,16 @@ app.on('ready', async () => { : path.normalize(`${emberAppDir}${absolutePath}`); if (isDev) console.log('[serving]', request.url); + + if (request.url.includes('/terminal/')) { + // Serve terminal view + const urlPath = new URL(request.url).pathname; + const filename = path.basename(urlPath); + const terminalViewPath = path.normalize( + `${__dirname}/../terminal-view-dist/${filename}`, + ); + return net.fetch(`file://${terminalViewPath}`); + } return net.fetch(`file://${normalizedPath}`); }); @@ -299,6 +310,8 @@ app.on('quit', () => { cacheDaemonManager.stop(); // we should stop any active RDP client processes rdpClientManager.stopAll(); + // destroy the terminal view + terminalManager.destroyTerminalView(); }); // Handle an unhandled error in the main thread diff --git a/ui/desktop/electron-app/src/ipc/handlers.js b/ui/desktop/electron-app/src/ipc/handlers.js index 3acc5bc83..c09618f76 100644 --- a/ui/desktop/electron-app/src/ipc/handlers.js +++ b/ui/desktop/electron-app/src/ipc/handlers.js @@ -13,14 +13,13 @@ const sanitizer = require('../utils/sanitizer.js'); const isLocalhost = require('../utils/isLocalhost'); const { isLinux, isMac, isWindows } = require('../helpers/platform.js'); const os = require('node:os'); -const pty = require('node-pty'); const which = require('which'); const cacheDaemonManager = require('../services/cache-daemon-manager'); const clientAgentDaemonManager = require('../services/client-agent-daemon-manager'); const { releaseVersion } = require('../../config/config.js'); const store = require('../services/electron-store-manager'); const rdpClientManager = require('../services/rdp-client-manager'); - +const terminalManager = require('../services/terminal-manager'); /** * Returns the current runtime clusterUrl, which is used by the main thread to * rewrite the CSP to allow requests. @@ -301,49 +300,33 @@ handle('launchRdpClient', async (sessionId) => * as we need access to the event and don't need to be using `ipcMain.handle`. */ ipcMain.on('createTerminal', (event, payload) => { - const { id, cols, rows } = payload; - const { sender } = event; - const terminalShell = isWindows() - ? 'powershell.exe' - : process.env.SHELL || '/bin/bash'; - const ptyProcess = pty.spawn(terminalShell, [], { - name: 'xterm-color', - cols, - rows, - cwd: process.env.HOME, - env: process.env, - }); - const incomingDataChannel = `terminalIncomingData-${id}`; - const keystrokeChannel = `terminalKeystroke-${id}`; - const resizeChannel = `resize-${id}`; - const removeChannel = `removeTerminal-${id}`; - - // This sends to the renderer and xterm whatever the ptyProcess (host terminal) outputs. - ptyProcess.on('data', function (data) { - sender.send(incomingDataChannel, data); - }); - - // This writes into ptyProcess (host terminal) whatever we write through xterm. - ipcMain.on(keystrokeChannel, (event, value) => { - ptyProcess.write(value); - }); - - // Resize the number of columns and rows received from xterm. - ipcMain.on(resizeChannel, (event, { cols, rows }) => { - ptyProcess.resize(cols, rows); - }); - - // We use `ipcMain.once` as we want this listener cleaned up - // after killing the ptyProcess as well. - ipcMain.once(removeChannel, () => { - // Just let the error bubble up since we can't do anything - try { - // TODO: Should we be killing entire process tree in windows with its pid? - ptyProcess.kill(); - } finally { - // Remove listeners - ipcMain.removeAllListeners(keystrokeChannel); - ipcMain.removeAllListeners(resizeChannel); - } - }); + terminalManager.createTerminal(ipcMain, event, payload); +}); + +/** + * Creates a terminal view and adds it to the main window. + */ +ipcMain.on('createTerminalView', (event, payload) => { + terminalManager.createTerminalView(payload); +}); + +/** + * Destroys the terminal view and removes it from the main window. + */ +ipcMain.on('destroyTerminalView', () => { + terminalManager.destroyTerminalView(); +}); + +/** + * Hides the terminal view from the main window + */ +ipcMain.on('hideTerminalView', () => { + terminalManager.hideTerminalView(); +}); + +/** + * Positions the terminal view within the main window based on the provided position + */ +ipcMain.on('positionTerminalView', (event, params) => { + terminalManager.positionTerminalView(params); }); diff --git a/ui/desktop/electron-app/src/preload.js b/ui/desktop/electron-app/src/preload.js index e7b8acfef..c79ecc882 100644 --- a/ui/desktop/electron-app/src/preload.js +++ b/ui/desktop/electron-app/src/preload.js @@ -3,7 +3,7 @@ * SPDX-License-Identifier: BUSL-1.1 */ -const { ipcRenderer, contextBridge, webFrame } = require('electron'); +const { ipcRenderer, contextBridge } = require('electron'); /** * Helper function to invoke IPC calls to properly handle errors @@ -93,12 +93,12 @@ contextBridge.exposeInMainWorld('desktop', { }, }); +/** + * Exposes `webContentView` via Electron ContextBridge to the renderer process for managing the terminal view + */ contextBridge.exposeInMainWorld('webContentView', { createTerminalView: (params) => { - ipcRenderer.send('createTerminalView', { - ...params, - zoomFactor: webFrame.getZoomFactor(), - }); + ipcRenderer.send('createTerminalView', params); }, destroyTerminalView: () => { ipcRenderer.send('destroyTerminalView'); @@ -107,9 +107,6 @@ contextBridge.exposeInMainWorld('webContentView', { ipcRenderer.send('hideTerminalView'); }, positionTerminalView: (position) => { - ipcRenderer.send('positionTerminalView', { - position, - zoomFactor: webFrame.getZoomFactor(), - }); + ipcRenderer.send('positionTerminalView', position); }, }); diff --git a/ui/desktop/electron-app/src/services/terminal-manager.js b/ui/desktop/electron-app/src/services/terminal-manager.js new file mode 100644 index 000000000..781122672 --- /dev/null +++ b/ui/desktop/electron-app/src/services/terminal-manager.js @@ -0,0 +1,143 @@ +/** + * Copyright IBM Corp. 2021, 2026 + * SPDX-License-Identifier: BUSL-1.1 + */ + +const { isWindows } = require('../helpers/platform.js'); +const pty = require('node-pty'); +const sessionManager = require('../services/session-manager.js'); +const { WebContentsView, BrowserWindow } = require('electron'); +const path = require('path'); + +class TerminalManager { + #terminalView = undefined; + #sessionId = undefined; + #autoSSH = false; + + get mainWindow() { + return BrowserWindow.getAllWindows()[0]; + } + + createTerminal(ipcMain, event, payload) { + const { id, cols, rows } = payload; + const terminalShell = isWindows() + ? 'powershell.exe' + : process.env.SHELL || '/bin/bash'; + const ptyProcess = pty.spawn(terminalShell, [], { + name: 'xterm-color', + cols, + rows, + cwd: process.env.HOME, + env: process.env, + }); + const session = sessionManager.getSessionById(this.#sessionId); + + // If autoSSH is enabled, we need to send the SSH command to the terminal once it's created + if (this.#autoSSH && session) { + const { + proxyDetails: { address, port }, + } = session; + const sshCommand = `ssh ${address} -p ${port} -o NoHostAuthenticationForLocalhost=yes\r`; + ptyProcess.write(sshCommand); + } + + const incomingDataChannel = `terminalIncomingData-${id}`; + const resizeChannel = `resizeTerminal-${id}`; + const removeChannel = `removeTerminal-${id}`; + const keystrokeChannel = `terminalKeystroke-${id}`; + + ptyProcess.on('data', (data) => { + event.sender.send(incomingDataChannel, data); + }); + + ipcMain.on(keystrokeChannel, (event, value) => { + ptyProcess.write(value); + }); + + // Resize listener + const resizeListener = (event, size) => { + const { cols, rows } = size; + ptyProcess.resize(cols, rows); + }; + ipcMain.on(resizeChannel, resizeListener); + + const cleanup = () => { + try { + ptyProcess.kill(); + } finally { + // Clean up all IPC listeners + ipcMain.removeListener(resizeChannel, resizeListener); + ipcMain.removeAllListeners(removeChannel); + ipcMain.removeAllListeners(keystrokeChannel); + } + }; + + ipcMain.once(removeChannel, cleanup); + } + + /** + * Creates a terminal view and adds it to the main window. The terminal view is sandboxed and has a preload script that sets up IPC communication for sending keystrokes and receiving data. + * @param {Object} vars - Variables for terminal creation + * @param {Object} position - The position and size of the terminal view + * @param {string} id - An optional identifier for the terminal session + * @param {boolean} autoSSH - Whether to automatically send an SSH command to the terminal upon creation + */ + createTerminalView({ position, id = undefined, autoSSH = false }) { + this.#sessionId = id; + this.#autoSSH = autoSSH; + this.#terminalView = new WebContentsView({ + webPreferences: { + sandbox: true, + webSecurity: true, + contextIsolation: true, + nodeIntegration: false, + preload: path.join(__dirname, '..', 'terminal-preload.js'), + partition: 'persist:boundary', + }, + }); + + this.mainWindow.contentView.addChildView(this.#terminalView); + this.positionTerminalView(position); + + this.#terminalView.webContents.loadURL( + 'serve://boundary/terminal/terminal.html', + ); + } + + positionTerminalView(position) { + if (!this.#terminalView) { + return; + } + const zoomFactor = this.mainWindow.webContents.getZoomFactor(); + // Keep terminal view zoom synced so that it scales correctly with the rest of the UI and appears at the correct size relative to the main window contents. + this.#terminalView.webContents.setZoomFactor(zoomFactor); + + const scaledPosition = { + x: Math.round(position.x * zoomFactor), + y: Math.round(position.y * zoomFactor), + width: Math.round(position.width * zoomFactor), + height: Math.round(position.height * zoomFactor), + }; + + this.#terminalView.setBounds(scaledPosition); + } + + hideTerminalView() { + if (this.#terminalView) { + this.#terminalView.setBounds({ x: 0, y: 0, width: 0, height: 0 }); + } + } + + destroyTerminalView() { + if (!this.#terminalView) { + return; + } + this.#terminalView.webContents.send('cleanupTerminal'); + this.mainWindow.contentView.removeChildView(this.#terminalView); + this.#terminalView = undefined; + this.#sessionId = undefined; + this.#autoSSH = false; + } +} + +module.exports = new TerminalManager(); diff --git a/ui/desktop/electron-app/src/terminal-preload.js b/ui/desktop/electron-app/src/terminal-preload.js new file mode 100644 index 000000000..62ba4ba9d --- /dev/null +++ b/ui/desktop/electron-app/src/terminal-preload.js @@ -0,0 +1,41 @@ +/** + * Copyright IBM Corp. 2021, 2026 + * SPDX-License-Identifier: BUSL-1.1 + */ + +/** + * This preload is for the sandboxed terminal renderer. + * Exposes `window.terminal` API (via `contextBridge`) used by + * `terminal-view/terminal.js` to connect xterm.js to a node-pty instance running + * in the main process. + */ + +const { ipcRenderer, contextBridge } = require('electron'); + +contextBridge.exposeInMainWorld('terminal', { + send: (data, id) => { + ipcRenderer.send(`terminalKeystroke-${id}`, data); + }, + onReceive: (callback, id) => { + const incomingDataChannel = `terminalIncomingData-${id}`; + const listenerCallback = (_event, value) => callback(value); + ipcRenderer.on(incomingDataChannel, listenerCallback); + + // Return a function for the caller to handle cleaning up the listener + return () => { + return ipcRenderer.removeListener(incomingDataChannel, listenerCallback); + }; + }, + create: (vars) => { + ipcRenderer.send('createTerminal', vars); + }, + remove: (id) => { + ipcRenderer.send(`removeTerminal-${id}`); + }, + resize: (size, id) => { + ipcRenderer.send(`resizeTerminal-${id}`, size); + }, + onCleanup: (callback) => { + ipcRenderer.once('cleanupTerminal', () => callback()); + }, +}); diff --git a/ui/desktop/electron-app/src/terminal-view/terminal.css b/ui/desktop/electron-app/src/terminal-view/terminal.css new file mode 100644 index 000000000..89ba57152 --- /dev/null +++ b/ui/desktop/electron-app/src/terminal-view/terminal.css @@ -0,0 +1,18 @@ +/** + * Copyright IBM Corp. 2021, 2026 + * SPDX-License-Identifier: BUSL-1.1 + */ + +#terminal { + width: 100%; + height: 100%; +} + +html, body { + width: 100%; + height: 100%; + margin: 0; + padding: 0; + overflow: hidden; + background: #000; +} \ No newline at end of file diff --git a/ui/desktop/electron-app/src/terminal-view/terminal.html b/ui/desktop/electron-app/src/terminal-view/terminal.html new file mode 100644 index 000000000..9e3127b34 --- /dev/null +++ b/ui/desktop/electron-app/src/terminal-view/terminal.html @@ -0,0 +1,18 @@ + + + + + + + + Terminal View + + + +
+ + + \ No newline at end of file diff --git a/ui/desktop/electron-app/src/terminal-view/terminal.js b/ui/desktop/electron-app/src/terminal-view/terminal.js new file mode 100644 index 000000000..5ce216b22 --- /dev/null +++ b/ui/desktop/electron-app/src/terminal-view/terminal.js @@ -0,0 +1,81 @@ +/** + * Copyright IBM Corp. 2021, 2026 + * SPDX-License-Identifier: BUSL-1.1 + */ + +/** + * This terminal renderer bootstraps an xterm.js instance and connects it to the Electron main process + * via the `window.terminal` API exposed by the preload `terminal-preload.js`. + */ + +import { Terminal } from '@xterm/xterm'; +import { FitAddon } from '@xterm/addon-fit'; +import { CanvasAddon } from '@xterm/addon-canvas'; +import { v4 as uuidv4 } from 'uuid'; +import '@xterm/xterm/css/xterm.css'; +import './terminal.css'; + +const terminalOptions = { + cursorBlink: true, +}; +const terminalId = uuidv4(); +const terminalElement = document.getElementById('terminal'); +const xterm = new Terminal(terminalOptions); +const fitAddon = new FitAddon(); +const canvasAddon = new CanvasAddon(); + +xterm.loadAddon(fitAddon); +xterm.loadAddon(canvasAddon); + +xterm.open(terminalElement); +fitAddon.fit(); +xterm.focus(); + +/** + * Custom key event handler for windows to achieve Ctrl+C as on the other OS's + */ +const isWindows = navigator.userAgent.toLowerCase().includes('win'); +if (isWindows) { + xterm.attachCustomKeyEventHandler(async (arg) => { + if (arg.ctrlKey && arg.code === 'KeyC' && arg.type === 'keydown') { + const selection = xterm.getSelection(); + if (selection) { + await navigator.clipboard.writeText(selection); + return false; + } + } + return true; + }); +} + +// create terminal through the terminal API exposed by terminal-preload script +window.terminal.create({ + id: terminalId, + cols: xterm.cols, + rows: xterm.rows, +}); + +xterm.onData((data) => { + window.terminal.send(data, terminalId); +}); + +const cleanupTerminalListener = window.terminal.onReceive((value) => { + xterm.write(value); +}, terminalId); + +xterm.onResize((size) => { + window.terminal.resize(size, terminalId); +}); + +const resizeObserver = new ResizeObserver(() => { + fitAddon.fit(); +}); + +resizeObserver.observe(terminalElement); + +window.terminal.onCleanup(() => { + resizeObserver.disconnect(); + cleanupTerminalListener(); + xterm.dispose(); + window.terminal.remove(terminalId); +}); diff --git a/ui/desktop/ember-cli-build.js b/ui/desktop/ember-cli-build.js index a112055fc..35f0a4c5b 100644 --- a/ui/desktop/ember-cli-build.js +++ b/ui/desktop/ember-cli-build.js @@ -61,7 +61,5 @@ module.exports = async function (defaults) { // please specify an object with the list of modules as keys // along with the exports of each module as its value. - app.import('node_modules/@xterm/xterm/css/xterm.css'); - return app.toTree(); }; diff --git a/ui/desktop/eslint.config.js b/ui/desktop/eslint.config.js index 3b70a83d6..62dc2e440 100644 --- a/ui/desktop/eslint.config.js +++ b/ui/desktop/eslint.config.js @@ -103,5 +103,7 @@ module.exports = defineConfig([ '.node_modules.ember-try/', 'electron-app/out/', 'electron-app/ember-dist/', + 'electron-app/terminal-view-dist/', + 'electron-app/src/terminal-view/**/*.js', ]), ]); diff --git a/ui/desktop/package.json b/ui/desktop/package.json index a23c44ff4..d18c074b1 100644 --- a/ui/desktop/package.json +++ b/ui/desktop/package.json @@ -43,9 +43,6 @@ "dependencies": { "@babel/runtime": "7.27.6", "@ember/string": "^4.0.0", - "@xterm/addon-canvas": "^0.7.0", - "@xterm/addon-fit": "^0.10.0", - "@xterm/xterm": "^5.5.0", "ember-a11y-refocus": "^5.0.0", "ember-inflector": "^6.0.0", "ember-named-blocks-polyfill": "^0.2.5", diff --git a/ui/desktop/tests/unit/services/terminal-test.js b/ui/desktop/tests/unit/services/terminal-test.js new file mode 100644 index 000000000..537c35b6c --- /dev/null +++ b/ui/desktop/tests/unit/services/terminal-test.js @@ -0,0 +1,108 @@ +/** + * Copyright IBM Corp. 2021, 2026 + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { module, test } from 'qunit'; +import { setupTest } from 'desktop/tests/helpers'; +import sinon from 'sinon'; + +module('Unit | Service | terminal', function (hooks) { + setupTest(hooks); + + let service; + let originalWebContentView; + + hooks.beforeEach(function () { + service = this.owner.lookup('service:terminal'); + originalWebContentView = window.webContentView; + + window.webContentView = { + positionTerminalView: sinon.stub(), + hideTerminalView: sinon.stub(), + createTerminalView: sinon.stub(), + destroyTerminalView: sinon.stub(), + }; + }); + + hooks.afterEach(function () { + window.webContentView = originalWebContentView; + sinon.restore(); + }); + + test('it exists', function (assert) { + assert.ok(service); + }); + + test('setTerminalTabActive hides the terminal when the terminal tab is not active', function (assert) { + service.setTerminalTabActive(true); + + assert.true(service.isTerminalTabActive); + assert.false(window.webContentView.hideTerminalView.called); + + service.setTerminalTabActive(false); + + assert.false(service.isTerminalTabActive); + assert.true(window.webContentView.hideTerminalView.calledOnce); + }); + + test('displayTerminalView positions an existing terminal view', function (assert) { + sinon.stub(service, 'terminalPosition').get(() => ({ + x: 10, + y: 20, + width: 300, + height: 676, + })); + + service.isTerminalViewCreated = true; + service.displayTerminalView(); + + assert.true(window.webContentView.positionTerminalView.calledOnce); + }); + + test('hideTerminalView hides the terminal', function (assert) { + service.hideTerminalView(); + assert.true(window.webContentView.hideTerminalView.calledOnce); + + window.webContentView.hideTerminalView.reset(); + + service.hideTerminalView(); + + assert.true(window.webContentView.hideTerminalView.calledOnce); + }); + + test('createTerminalView is called with the correct payload', function (assert) { + const expectedPosition = { + x: 10, + y: 20, + width: 300, + height: 676, + }; + + sinon.stub(service, 'terminalPosition').get(() => expectedPosition); + const payload = { sessionId: 'abc123', autoSSH: false }; + + service.createTerminalView(payload); + + assert.true(window.webContentView.createTerminalView.calledOnce); + const updatedPayload = + window.webContentView.createTerminalView.firstCall.args[0]; + assert.strictEqual(updatedPayload.sessionId, 'abc123'); + assert.false(updatedPayload.autoSSH); + assert.deepEqual(updatedPayload.position, expectedPosition); + + assert.true(service.isTerminalViewCreated); + }); + + test('cleanup destroys the terminal view', function (assert) { + service.isTerminalViewCreated = true; + service.isTerminalTabActive = true; + + service.cleanup(); + + assert.true(window.webContentView.destroyTerminalView.calledOnce); + assert.false(service.isTerminalViewCreated); + assert.false(service.isTerminalTabActive); + assert.false(service.isSideNavMinimized); + }); +});