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:
1 parent
605b7940e3
commit
f48e6dc049
25 files changed
+1097
-214
No files matched your search
Generated
-32
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Generated
+393
@@ -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: {}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
};
|
};
|
||||||
@@ -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',
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user