Refactor: Load terminal using web contents view (#3190)

* 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
This commit is contained in:
Priya Patel authored and GitHub committed 2026-03-30 10:08:55 -07:00
1 parent 605b7940e3
commit f48e6dc049
25 files changed
+1097 -214

No files matched your search

-32
View File
@@ -1015,15 +1015,6 @@ importers:
'@ember/string': '@ember/string':
specifier: ^4.0.0 specifier: ^4.0.0
version: 4.0.1 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: ember-a11y-refocus:
specifier: ^5.0.0 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))) 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==} resolution: {integrity: sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==}
engines: {node: '>=10.0.0'} 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': '@xtuc/ieee754@1.2.0':
resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==}
@@ -12083,16 +12061,6 @@ snapshots:
'@xmldom/xmldom@0.8.10': {} '@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/ieee754@1.2.0': {}
'@xtuc/long@4.2.2': {} '@xtuc/long@4.2.2': {}
@@ -17,7 +17,7 @@
{{else}} {{else}}
{{! Non-RDP: Show tabbed interface }} {{! Non-RDP: Show tabbed interface }}
<Hds::Tabs as |T|> <Hds::Tabs as |T|>
<T.Tab>{{t 'titles.details'}}</T.Tab> <T.Tab {{on 'click' this.openDetails}}>{{t 'titles.details'}}</T.Tab>
<T.Tab @icon='terminal' {{on 'click' this.openTerminal}}> <T.Tab @icon='terminal' {{on 'click' this.openTerminal}}>
{{t 'resources.session.shell'}} {{t 'resources.session.shell'}}
</T.Tab> </T.Tab>
+28 -108
View File
@@ -5,132 +5,52 @@
import Component from '@glimmer/component'; import Component from '@glimmer/component';
import { action } from '@ember/object'; import { action } from '@ember/object';
import { Terminal } from '@xterm/xterm'; import { service } from '@ember/service';
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`;
};
export default class SessionTerminalTabsComponent extends Component { export default class SessionTerminalTabsComponent extends Component {
// =attributes // =services
id; @service terminal;
terminal;
removeTerminalListener;
// =actions // =actions
@action @action
async openTerminal() { async openTerminal() {
// Don't re-initialize the terminal if it's already been opened this.terminal.setTerminalTabActive(true);
if (this.terminal) {
// If the terminal view is already created, just display the existing one.
if (this.terminal.isTerminalViewCreated) {
this.terminal.displayTerminalView();
return; return;
} }
const { isWindows } = await window.desktop.system.checkOS(); // If the terminal view has not been created yet, we create it.
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);
const isSSHCommandAvailable = const isSSHCommandAvailable =
await window.desktop.system.checkCommand('ssh'); 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: const payload = {
// 1. Only for SSH targets id,
// 2. Don't connect if the session wasn't initiated in the desktop client autoSSH,
// 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) { this.terminal.createTerminalView(payload);
// Send an SSH command immediately }
window.terminal.send(
`ssh ${proxy_address} -p ${proxy_port} -o NoHostAuthenticationForLocalhost=yes\r`, /**
this.id, * When the details tab is clicked, we want to hide the terminal view
); */
} @action
openDetails() {
this.terminal.setTerminalTabActive(false);
} }
willDestroy() { willDestroy() {
super.willDestroy(...arguments); super.willDestroy(...arguments);
this.terminal.cleanup();
// 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);
});
} }
} }
+35
View File
@@ -21,6 +21,7 @@ export default class ApplicationController extends Controller {
@service router; @service router;
@service scope; @service scope;
@service intl; @service intl;
@service terminal;
// =attributes // =attributes
@@ -35,6 +36,7 @@ export default class ApplicationController extends Controller {
// Setup removeOnAppQuitListener to destroy the listener afterwards // Setup removeOnAppQuitListener to destroy the listener afterwards
this.removeOnAppQuitListener = this.window.desktop?.app.onAppQuit(() => { this.removeOnAppQuitListener = this.window.desktop?.app.onAppQuit(() => {
this.isAppQuitting = true; this.isAppQuitting = true;
this.hideTerminalViewForModal();
}); });
} }
@@ -91,6 +93,7 @@ export default class ApplicationController extends Controller {
await window.desktop.session.hasRunningSessions(); await window.desktop.session.hasRunningSessions();
if (hasRunningSessions) { if (hasRunningSessions) {
this.isLoggingOut = true; this.isLoggingOut = true;
this.hideTerminalViewForModal();
} else { } else {
this.session.invalidate(); this.session.invalidate();
} }
@@ -151,6 +154,9 @@ export default class ApplicationController extends Controller {
cancel() { cancel() {
this.isLoggingOut = false; this.isLoggingOut = false;
this.isAppQuitting = false; this.isAppQuitting = false;
if (this.terminal.isTerminalTabActive) {
this.terminal.displayTerminalView();
}
} }
willDestroy() { willDestroy() {
@@ -182,4 +188,33 @@ export default class ApplicationController extends Controller {
} }
return defaultValidator(transition); 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();
}
}
}
} }
@@ -25,6 +25,7 @@ export default class ScopesScopeProjectsSessionsIndexController extends Controll
@service session; @service session;
@service router; @service router;
@service abilities; @service abilities;
@service terminal;
// =attributes // =attributes
@@ -154,14 +155,19 @@ export default class ScopesScopeProjectsSessionsIndexController extends Controll
}); });
} }
await updatedSession.cancelSession(); try {
await updatedSession.cancelSession();
await window.desktop.session.stopSession({ session_id: session.id }); await window.desktop.session.stopSession({ session_id: session.id });
if ( if (
this.router.currentRoute.name === this.router.currentRoute.name ===
'scopes.scope.projects.sessions.session.index' 'scopes.scope.projects.sessions.session.index'
) { ) {
this.router.replaceWith('scopes.scope.projects.targets.index'); this.router.replaceWith('scopes.scope.projects.targets.index');
}
} catch (e) {
// hide terminal view
this.terminal.hideTerminalView();
throw e;
} }
} }
+3
View File
@@ -16,6 +16,7 @@ export default class ApplicationRoute extends Route {
@service clusterUrl; @service clusterUrl;
@service intl; @service intl;
@service rdp; @service rdp;
@service terminal;
// =attributes // =attributes
@@ -83,6 +84,8 @@ export default class ApplicationRoute extends Route {
@action @action
error(e) { error(e) {
const isUnauthenticated = A(e?.errors)?.[0]?.isUnauthenticated; const isUnauthenticated = A(e?.errors)?.[0]?.isUnauthenticated;
// clean up terminal view for all errors
this.terminal.cleanup();
if (isUnauthenticated) { if (isUnauthenticated) {
this.session.invalidate(); this.session.invalidate();
return false; return false;
+4
View File
@@ -11,6 +11,7 @@ export default class IndexRoute extends Route {
@service clusterUrl; @service clusterUrl;
@service router; @service router;
@service terminal;
// =methods // =methods
@@ -18,6 +19,9 @@ export default class IndexRoute extends Route {
* If no clusterUrl is specified yet, redirects to cluster-url, otherwise scopes. * If no clusterUrl is specified yet, redirects to cluster-url, otherwise scopes.
*/ */
redirect() { redirect() {
// cleanup terminal view when navigating to index route - on window reload
this.terminal.cleanup();
const rendererClusterUrl = this.clusterUrl.rendererClusterUrl; const rendererClusterUrl = this.clusterUrl.rendererClusterUrl;
if (!rendererClusterUrl) { if (!rendererClusterUrl) {
this.router.replaceWith('cluster-url'); this.router.replaceWith('cluster-url');
+102
View File
@@ -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;
}
}
+1 -1
View File
@@ -133,7 +133,7 @@
</Frame.Header> </Frame.Header>
<Frame.Sidebar> <Frame.Sidebar>
<Hds::AppSideNav> <Hds::AppSideNav @onToggleMinimizedStatus={{this.handleSideNavToggle}}>
<Hds::AppSideNav::Portal::Target /> <Hds::AppSideNav::Portal::Target />
</Hds::AppSideNav> </Hds::AppSideNav>
</Frame.Sidebar> </Frame.Sidebar>
+3
View File
@@ -90,6 +90,9 @@ config/config.js
ember-dist/ ember-dist/
ember-test/ ember-test/
# Terminal view build output
terminal-view-dist/
# CLI # CLI
/cli/ /cli/
@@ -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();
},
};
+9 -3
View File
@@ -10,7 +10,8 @@
"scripts": { "scripts": {
"build:cli": "node -e \"require('./config/cli.js').setup()\"", "build:cli": "node -e \"require('./config/cli.js').setup()\"",
"build:config": "node -e \"require('./config/desktop.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", "start": "electron-forge start",
"package": "electron-forge package", "package": "electron-forge package",
"make": "pnpm build:all && electron-forge make", "make": "pnpm build:all && electron-forge make",
@@ -22,6 +23,9 @@
"forge": "config/forge.config.js" "forge": "config/forge.config.js"
}, },
"dependencies": { "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-devtools-installer": "^3.2.0",
"electron-is-dev": "^2.0.0", "electron-is-dev": "^2.0.0",
"electron-log": "^5.1.2", "electron-log": "^5.1.2",
@@ -41,6 +45,7 @@
"@electron-forge/maker-squirrel": "^7.7.0", "@electron-forge/maker-squirrel": "^7.7.0",
"@electron-forge/maker-zip": "^7.7.0", "@electron-forge/maker-zip": "^7.7.0",
"electron": "38.2.1", "electron": "38.2.1",
"esbuild": "^0.25.4",
"unzipper": "^0.11.5" "unzipper": "^0.11.5"
}, },
"pnpm": { "pnpm": {
@@ -51,9 +56,10 @@
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [
"electron", "electron",
"electron-winstaller", "electron-winstaller",
"esbuild",
"fs-xattr", "fs-xattr",
"node-pty", "macos-alias",
"macos-alias" "node-pty"
] ]
} }
} }
+393
View File
@@ -10,6 +10,15 @@ overrides:
importers: importers:
.: .:
dependencies: 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: electron-devtools-installer:
specifier: ^3.2.0 specifier: ^3.2.0
version: 3.2.1 version: 3.2.1
@@ -62,6 +71,9 @@ importers:
electron: electron:
specifier: 38.2.1 specifier: 38.2.1
version: 38.2.1 version: 38.2.1
esbuild:
specifier: ^0.25.4
version: 0.25.12
unzipper: unzipper:
specifier: ^0.11.5 specifier: ^0.11.5
version: 0.11.6 version: 0.11.6
@@ -264,6 +276,240 @@ packages:
engines: { node: '>=14.14' } engines: { node: '>=14.14' }
hasBin: true 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': '@gar/promisify@1.1.3':
resolution: resolution:
{ {
@@ -403,6 +649,28 @@ packages:
} }
engines: { node: '>=10.0.0' } 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: abbrev@1.1.1:
resolution: resolution:
{ {
@@ -1139,6 +1407,14 @@ packages:
integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==, integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==,
} }
esbuild@0.25.12:
resolution:
{
integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==,
}
engines: { node: '>=18' }
hasBin: true
escalade@3.2.0: escalade@3.2.0:
resolution: resolution:
{ {
@@ -3610,6 +3886,84 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - 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': {} '@gar/promisify@1.1.3': {}
'@isaacs/fs-minipass@4.0.1': '@isaacs/fs-minipass@4.0.1':
@@ -3693,6 +4047,16 @@ snapshots:
'@xmldom/xmldom@0.8.10': {} '@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: {} abbrev@1.1.1: {}
agent-base@6.0.2: agent-base@6.0.2:
@@ -4163,6 +4527,35 @@ snapshots:
es6-error@4.1.1: es6-error@4.1.1:
optional: true 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: {} escalade@3.2.0: {}
escape-string-regexp@1.0.5: {} escape-string-regexp@1.0.5: {}
+13
View File
@@ -23,6 +23,7 @@ const sessionManager = require('./services/session-manager.js');
const cacheDaemonManager = require('./services/cache-daemon-manager'); const cacheDaemonManager = require('./services/cache-daemon-manager');
const rdpClientManager = require('./services/rdp-client-manager'); const rdpClientManager = require('./services/rdp-client-manager');
const store = require('./services/electron-store-manager'); const store = require('./services/electron-store-manager');
const terminalManager = require('./services/terminal-manager');
const menu = require('./config/menu.js'); const menu = require('./config/menu.js');
const appUpdater = require('./helpers/app-updater.js'); const appUpdater = require('./helpers/app-updater.js');
@@ -223,6 +224,16 @@ app.on('ready', async () => {
: path.normalize(`${emberAppDir}${absolutePath}`); : path.normalize(`${emberAppDir}${absolutePath}`);
if (isDev) console.log('[serving]', request.url); 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}`); return net.fetch(`file://${normalizedPath}`);
}); });
@@ -299,6 +310,8 @@ app.on('quit', () => {
cacheDaemonManager.stop(); cacheDaemonManager.stop();
// we should stop any active RDP client processes // we should stop any active RDP client processes
rdpClientManager.stopAll(); rdpClientManager.stopAll();
// destroy the terminal view
terminalManager.destroyTerminalView();
}); });
// Handle an unhandled error in the main thread // Handle an unhandled error in the main thread
+30 -47
View File
@@ -13,14 +13,13 @@ const sanitizer = require('../utils/sanitizer.js');
const isLocalhost = require('../utils/isLocalhost'); const isLocalhost = require('../utils/isLocalhost');
const { isLinux, isMac, isWindows } = require('../helpers/platform.js'); const { isLinux, isMac, isWindows } = require('../helpers/platform.js');
const os = require('node:os'); const os = require('node:os');
const pty = require('node-pty');
const which = require('which'); const which = require('which');
const cacheDaemonManager = require('../services/cache-daemon-manager'); const cacheDaemonManager = require('../services/cache-daemon-manager');
const clientAgentDaemonManager = require('../services/client-agent-daemon-manager'); const clientAgentDaemonManager = require('../services/client-agent-daemon-manager');
const { releaseVersion } = require('../../config/config.js'); const { releaseVersion } = require('../../config/config.js');
const store = require('../services/electron-store-manager'); const store = require('../services/electron-store-manager');
const rdpClientManager = require('../services/rdp-client-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 * Returns the current runtime clusterUrl, which is used by the main thread to
* rewrite the CSP to allow requests. * 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`. * as we need access to the event and don't need to be using `ipcMain.handle`.
*/ */
ipcMain.on('createTerminal', (event, payload) => { ipcMain.on('createTerminal', (event, payload) => {
const { id, cols, rows } = payload; terminalManager.createTerminal(ipcMain, event, payload);
const { sender } = event; });
const terminalShell = isWindows()
? 'powershell.exe' /**
: process.env.SHELL || '/bin/bash'; * Creates a terminal view and adds it to the main window.
const ptyProcess = pty.spawn(terminalShell, [], { */
name: 'xterm-color', ipcMain.on('createTerminalView', (event, payload) => {
cols, terminalManager.createTerminalView(payload);
rows, });
cwd: process.env.HOME,
env: process.env, /**
}); * Destroys the terminal view and removes it from the main window.
const incomingDataChannel = `terminalIncomingData-${id}`; */
const keystrokeChannel = `terminalKeystroke-${id}`; ipcMain.on('destroyTerminalView', () => {
const resizeChannel = `resize-${id}`; terminalManager.destroyTerminalView();
const removeChannel = `removeTerminal-${id}`; });
// This sends to the renderer and xterm whatever the ptyProcess (host terminal) outputs. /**
ptyProcess.on('data', function (data) { * Hides the terminal view from the main window
sender.send(incomingDataChannel, data); */
}); ipcMain.on('hideTerminalView', () => {
terminalManager.hideTerminalView();
// This writes into ptyProcess (host terminal) whatever we write through xterm. });
ipcMain.on(keystrokeChannel, (event, value) => {
ptyProcess.write(value); /**
}); * Positions the terminal view within the main window based on the provided position
*/
// Resize the number of columns and rows received from xterm. ipcMain.on('positionTerminalView', (event, params) => {
ipcMain.on(resizeChannel, (event, { cols, rows }) => { terminalManager.positionTerminalView(params);
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);
}
});
}); });
+6 -9
View File
@@ -3,7 +3,7 @@
* SPDX-License-Identifier: BUSL-1.1 * 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 * 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', { contextBridge.exposeInMainWorld('webContentView', {
createTerminalView: (params) => { createTerminalView: (params) => {
ipcRenderer.send('createTerminalView', { ipcRenderer.send('createTerminalView', params);
...params,
zoomFactor: webFrame.getZoomFactor(),
});
}, },
destroyTerminalView: () => { destroyTerminalView: () => {
ipcRenderer.send('destroyTerminalView'); ipcRenderer.send('destroyTerminalView');
@@ -107,9 +107,6 @@ contextBridge.exposeInMainWorld('webContentView', {
ipcRenderer.send('hideTerminalView'); ipcRenderer.send('hideTerminalView');
}, },
positionTerminalView: (position) => { positionTerminalView: (position) => {
ipcRenderer.send('positionTerminalView', { ipcRenderer.send('positionTerminalView', position);
position,
zoomFactor: webFrame.getZoomFactor(),
});
}, },
}); });
@@ -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();
@@ -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());
},
});
@@ -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;
}
@@ -0,0 +1,18 @@
<!DOCTYPE html>
<!--
Copyright IBM Corp. 2021, 2026
SPDX-License-Identifier: BUSL-1.1
-->
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Terminal View</title>
<link rel="stylesheet" href="./terminal.css" />
</head>
<body>
<div id="terminal"></div>
<script type="module" src="./terminal.js"></script>
</body>
</html>
@@ -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);
});
-2
View File
@@ -61,7 +61,5 @@ module.exports = async function (defaults) {
// please specify an object with the list of modules as keys // please specify an object with the list of modules as keys
// along with the exports of each module as its value. // along with the exports of each module as its value.
app.import('node_modules/@xterm/xterm/css/xterm.css');
return app.toTree(); return app.toTree();
}; };
+2
View File
@@ -103,5 +103,7 @@ module.exports = defineConfig([
'.node_modules.ember-try/', '.node_modules.ember-try/',
'electron-app/out/', 'electron-app/out/',
'electron-app/ember-dist/', 'electron-app/ember-dist/',
'electron-app/terminal-view-dist/',
'electron-app/src/terminal-view/**/*.js',
]), ]),
]); ]);
-3
View File
@@ -43,9 +43,6 @@
"dependencies": { "dependencies": {
"@babel/runtime": "7.27.6", "@babel/runtime": "7.27.6",
"@ember/string": "^4.0.0", "@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-a11y-refocus": "^5.0.0",
"ember-inflector": "^6.0.0", "ember-inflector": "^6.0.0",
"ember-named-blocks-polyfill": "^0.2.5", "ember-named-blocks-polyfill": "^0.2.5",
@@ -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);
});
});