deploy: current vibn theia state
Some checks failed
Playwright Tests / Playwright Tests (ubuntu-22.04, Node.js 22.x) (push) Has been cancelled
3PP License Check / 3PP License Check (11, 22.x, ubuntu-22.04) (push) Has been cancelled
Publish packages to NPM / Perform Publishing (push) Has been cancelled

Made-with: Cursor
This commit is contained in:
2026-02-27 12:01:08 -08:00
commit 8bb5110148
3782 changed files with 640947 additions and 0 deletions

View File

@@ -0,0 +1,10 @@
/** @type {import('eslint').Linter.Config} */
module.exports = {
extends: [
'../../configs/build.eslintrc.json'
],
parserOptions: {
tsconfigRootDir: __dirname,
project: 'tsconfig.json'
}
};

61
packages/remote/README.md Normal file
View File

@@ -0,0 +1,61 @@
<div align='center'>
<br />
<img src='https://raw.githubusercontent.com/eclipse-theia/theia/master/logo/theia.svg?sanitize=true' alt='theia-ext-logo' width='100px' />
<h2>ECLIPSE THEIA - REMOTE EXTENSION</h2>
<hr />
</div>
## Description
This package implements functionality to connect to remote systems using Theia.
This facilitates features similar to the features offered by Microsoft's popular `Remote-SSH`, `Dev Containers` or `WSL` extensions for VSCode.
## Package Architecture
The following explains the basic flow of any remote connection. It will be exemplified using the remote SSH feature:
1. When the user runs the `SSH: Connect to Host...` command, we send the host info to the local backend.
The corresponding `RemoteSSHConnectionProvider` is scoped to the current connection and can request additional information from the user, such as SSH key passphrases.
2. Once the `RemoteSSHConnectionProvider` has every information it needs, it creates a SSH connection and registers this connection to the general `RemoteConnectionService`.
Every `RemoteConnection` type implements an interface that is able to handle 3 kinds of messages to the remote system:
1. Executing commands in the shell of the remote system
2. Copying data to the remote
3. Once the connection has been established, a setup process takes place on the remote system:
1. Identifying the remote platform (i.e. Windows, MacOS or Linux). This information is needed for all the following steps.
2. Setting up various directories for storing the application and its dependencies.
3. Download and install the correct Node.js version for the remote platform.
4. Packaging, copying, and unpackaging the local backend to the remote backend.
1. Every Theia extension can register `RemoteCopyContribution` binding to copy certain files from the current system.
This contribution point is used for files that are used in all operating systems.
2. They can also register `RemoteNativeDependencyContribution` bindings to download and copy native dependencies for the remote system.
The downloaded files are on a per-platform basis.
5. Using the node version that was installed in step 3, we now start the `main.js` of the backend application.
We start the backend with `--port=0`, so that it searches for any available port. It will print the port to the console.
The setup either returns with a setup error or the port of the remote server on the remote system.
4. With the remote server/port in place, the backend sets up a local proxy server on a random port.
It instructs the `RemoteConnection` object to forward any HTTP request to this proxy server to the remote server.
5. The backend will return from the initial request from (1) with a new local proxy port. The frontend sets this port in the url and reload itself.
6. The frontend is now connected to the remote backend by connecting to the local proxy port.
7. The frontend now performs its normal messaging lifecycle, establishing connections to backend services.
Although these backend services live on a different remote system, the frontend handles them as if they belong to the local backend.
## Additional Information
- [API documentation for `@theia/remote`](https://eclipse-theia.github.io/theia/docs/next/modules/_theia_remote.html)
- [Theia - GitHub](https://github.com/eclipse-theia/theia)
- [Theia - Website](https://theia-ide.org/)
## License
- [Eclipse Public License 2.0](http://www.eclipse.org/legal/epl-2.0/)
- [一 (Secondary) GNU General Public License, version 2 with the GNU Classpath Exception](https://projects.eclipse.org/license/secondary-gpl-2.0-cp)
## Trademark
"Theia" is a trademark of the Eclipse Foundation
<https://www.eclipse.org/theia>

View File

@@ -0,0 +1,70 @@
{
"name": "@theia/remote",
"version": "1.68.0",
"description": "Theia - Remote",
"dependencies": {
"@theia/core": "1.68.0",
"@theia/filesystem": "1.68.0",
"@theia/userstorage": "1.68.0",
"@theia/variable-resolver": "1.68.0",
"archiver": "^5.3.1",
"decompress": "^4.2.1",
"decompress-tar": "^4.0.0",
"decompress-targz": "^4.0.0",
"decompress-unzip": "^4.0.1",
"express-http-proxy": "^2.1.1",
"glob": "^8.1.0",
"socket.io": "^4.5.3",
"socket.io-client": "^4.5.3",
"ssh-config": "^5.0.3",
"ssh2": "^1.15.0",
"ssh2-sftp-client": "^9.1.0",
"tslib": "^2.6.2"
},
"publishConfig": {
"access": "public"
},
"theiaExtensions": [
{
"frontendElectron": "lib/electron-browser/remote-frontend-module",
"backendElectron": "lib/electron-node/remote-backend-module"
}
],
"keywords": [
"theia-extension"
],
"license": "EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0",
"repository": {
"type": "git",
"url": "https://github.com/eclipse-theia/theia.git"
},
"bugs": {
"url": "https://github.com/theia-ide/theia/issues"
},
"homepage": "https://github.com/theia-ide/theia",
"files": [
"lib",
"src"
],
"scripts": {
"build": "theiaext build",
"clean": "theiaext clean",
"compile": "theiaext compile",
"lint": "theiaext lint",
"test": "theiaext test",
"watch": "theiaext watch"
},
"devDependencies": {
"@theia/ext-scripts": "1.68.0",
"@types/archiver": "^5.3.2",
"@types/decompress": "^4.2.4",
"@types/express-http-proxy": "^1.6.6",
"@types/glob": "^8.1.0",
"@types/ssh2": "^1.11.11",
"@types/ssh2-sftp-client": "^9.0.0"
},
"nyc": {
"extends": "../../configs/nyc.json"
},
"gitHead": "21358137e41342742707f660b8e222f940a27652"
}

View File

@@ -0,0 +1,31 @@
// *****************************************************************************
// Copyright (C) 2024 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { RpcProxy } from '@theia/core';
import { inject, injectable } from '@theia/core/shared/inversify';
import { RemoteFileSystemProvider, RemoteFileSystemServer } from '@theia/filesystem/lib/common/remote-file-system-provider';
export const LocalEnvVariablesServer = Symbol('LocalEnviromentVariableServer');
export const LocalRemoteFileSytemServer = Symbol('LocalRemoteFileSytemServer');
/**
* provide file access to local files while connected to a remote workspace or dev container.
*/
@injectable()
export class LocalRemoteFileSystemProvider extends RemoteFileSystemProvider {
@inject(LocalRemoteFileSytemServer)
protected override readonly server: RpcProxy<RemoteFileSystemServer>;
}

View File

@@ -0,0 +1,33 @@
// *****************************************************************************
// Copyright (C) 2024 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { nls } from '@theia/core';
import { AbstractViewContribution } from '@theia/core/lib/browser';
import { injectable } from '@theia/core/shared/inversify';
import { PortForwardingWidget, PORT_FORWARDING_WIDGET_ID } from './port-forwarding-widget';
@injectable()
export class PortForwardingContribution extends AbstractViewContribution<PortForwardingWidget> {
constructor() {
super({
widgetId: PORT_FORWARDING_WIDGET_ID,
widgetName: nls.localizeByDefault('Ports'),
defaultWidgetOptions: {
area: 'bottom'
}
});
}
}

View File

@@ -0,0 +1,93 @@
// *****************************************************************************
// Copyright (C) 2024 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { Emitter } from '@theia/core';
import { inject, injectable, postConstruct } from '@theia/core/shared/inversify';
import { RemotePortForwardingProvider } from '../../electron-common/remote-port-forwarding-provider';
import { getCurrentPort } from '@theia/core/lib/electron-browser/messaging/electron-local-ws-connection-source';
export interface ForwardedPort {
localPort?: number;
address?: string;
origin?: string;
editing: boolean;
}
@injectable()
export class PortForwardingService {
@inject(RemotePortForwardingProvider)
readonly provider: RemotePortForwardingProvider;
protected readonly onDidChangePortsEmitter = new Emitter<void>();
readonly onDidChangePorts = this.onDidChangePortsEmitter.event;
forwardedPorts: ForwardedPort[] = [];
@postConstruct()
init(): void {
this.provider.getForwardedPorts().then(ports => {
this.forwardedPorts.push(...ports.map(p => ({ address: p.address, localPort: p.port, editing: false })));
this.onDidChangePortsEmitter.fire();
});
}
forwardNewPort(origin?: string): ForwardedPort {
const index = this.forwardedPorts.push({ editing: true, origin });
return this.forwardedPorts[index - 1];
}
updatePort(port: ForwardedPort, newAdress: string): void {
const connectionPort = getCurrentPort();
if (!connectionPort) {
// if there is no open remote connection we can't forward a port
return;
}
const parts = newAdress.split(':');
if (parts.length === 2) {
port.address = parts[0];
port.localPort = parseInt(parts[1]);
} else {
port.localPort = parseInt(parts[0]);
}
port.editing = false;
this.provider.forwardPort(parseInt(connectionPort), { port: port.localPort!, address: port.address });
this.onDidChangePortsEmitter.fire();
}
removePort(port: ForwardedPort): void {
const index = this.forwardedPorts.indexOf(port);
if (index !== -1) {
this.forwardedPorts.splice(index, 1);
this.provider.portRemoved({ port: port.localPort! });
this.onDidChangePortsEmitter.fire();
}
}
isValidAddress(address: string): boolean {
const match = address.match(/^(.*:)?\d+$/);
if (!match) {
return false;
}
const port = parseInt(address.includes(':') ? address.split(':')[1] : address);
return !this.forwardedPorts.some(p => p.localPort === port);
}
}

View File

@@ -0,0 +1,140 @@
// *****************************************************************************
// Copyright (C) 2024 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import * as React from '@theia/core/shared/react';
import { ReactNode } from '@theia/core/shared/react';
import { OpenerService, ReactWidget } from '@theia/core/lib/browser';
import { nls, URI } from '@theia/core';
import { inject, injectable, postConstruct } from '@theia/core/shared/inversify';
import { ForwardedPort, PortForwardingService } from './port-forwarding-service';
import { ClipboardService } from '@theia/core/lib/browser/clipboard-service';
export const PORT_FORWARDING_WIDGET_ID = 'port-forwarding-widget';
@injectable()
export class PortForwardingWidget extends ReactWidget {
@inject(PortForwardingService)
protected readonly portForwardingService: PortForwardingService;
@inject(OpenerService)
protected readonly openerService: OpenerService;
@inject(ClipboardService)
protected readonly clipboardService: ClipboardService;
@postConstruct()
protected init(): void {
this.id = PORT_FORWARDING_WIDGET_ID;
this.node.tabIndex = -1;
this.title.label = nls.localizeByDefault('Ports');
this.title.caption = this.title.label;
this.title.closable = true;
this.update();
this.portForwardingService.onDidChangePorts(() => this.update());
}
protected render(): ReactNode {
if (this.portForwardingService.forwardedPorts.length === 0) {
return <div>
<p style={{ marginLeft: 'calc(var(--theia-ui-padding) * 2)' }}>
{nls.localizeByDefault('No forwarded ports. Forward a port to access your locally running services over the internet.\n[Forward a Port]({0})').split('\n')[0]}
</p>
{this.renderForwardPortButton()}
</div>;
}
return <div>
<table className='port-table'>
<thead>
<tr>
<th className='port-table-header'>{nls.localizeByDefault('Port')}</th>
<th className='port-table-header'>{nls.localizeByDefault('Address')}</th>
<th className='port-table-header'>{nls.localizeByDefault('Running Process')}</th>
<th className='port-table-header'>{nls.localizeByDefault('Origin')}</th>
</tr>
</thead>
<tbody>
{this.portForwardingService.forwardedPorts.map(port => (
<tr key={port.address && port.localPort ? `${port.address}:${port.localPort}` : 'editing'}>
{this.renderPortColumn(port)}
{this.renderAddressColumn(port)}
<td></td>
<td>{port.origin ? nls.localizeByDefault(port.origin) : ''}</td>
</tr>
))}
{!this.portForwardingService.forwardedPorts.some(port => port.editing) && <tr><td>{this.renderForwardPortButton()}</td></tr>}
</tbody>
</table>
</div>;
}
protected renderForwardPortButton(): ReactNode {
return <button className='theia-button' onClick={() => {
this.portForwardingService.forwardNewPort('User Forwarded');
this.update();
}
}>{nls.localizeByDefault('Forward a Port')}</button>;
}
protected renderAddressColumn(port: ForwardedPort): ReactNode {
const address = `${port.address ?? '0.0.0.0'}:${port.localPort}`;
return <td>
<div className='button-cell'>
<span style={{ flexGrow: 1 }} className='forwarded-address' onClick={async e => {
if (e.ctrlKey) {
const uri = new URI(`http://${address}`);
(await this.openerService.getOpener(uri)).open(uri);
}
}} title={nls.localizeByDefault('Follow link') + ' (ctrl/cmd + click)'}>
{port.localPort ? address : ''}
</span>
{
port.localPort &&
<span className='codicon codicon-clippy action-label' title={nls.localizeByDefault('Copy Local Address')} onClick={() => {
this.clipboardService.writeText(address);
}}></span>
}
</div>
</td>;
}
protected renderPortColumn(port: ForwardedPort): ReactNode {
return port.editing ?
<td><PortEditingInput port={port} service={this.portForwardingService} /></td> :
<td>
<div className='button-cell'>
<span style={{ flexGrow: 1 }}>{port.localPort}</span>
<span className='codicon codicon-close action-label' title={nls.localizeByDefault('Stop Forwarding Port')} onClick={() => {
this.portForwardingService.removePort(port);
this.update();
}}></span>
</div>
</td>;
}
}
function PortEditingInput({ port, service }: { port: ForwardedPort, service: PortForwardingService }): React.JSX.Element {
const [error, setError] = React.useState(false);
return <input className={`theia-input forward-port-button${error ? ' port-edit-input-error' : ''}`} port-edit-input-error={error}
autoFocus defaultValue={port.address ? `${port.address}:${port.localPort}` : port.localPort ?? ''}
placeholder={nls.localizeByDefault('Port number or address (eg. 3000 or 10.10.10.10:2000).')}
onKeyDown={e => e.key === 'Enter' && !error && service.updatePort(port, e.currentTarget.value)}
onKeyUp={e => setError(!service.isValidAddress(e.currentTarget.value))}></input>;
}

View File

@@ -0,0 +1,47 @@
// *****************************************************************************
// Copyright (C) 2023 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { MaybeArray, URI } from '@theia/core';
import { inject, injectable } from '@theia/core/shared/inversify';
import { OpenFileDialogProps, SaveFileDialogProps } from '@theia/filesystem/lib/browser/file-dialog';
import { FileStat } from '@theia/filesystem/lib/common/files';
import { DefaultFileDialogService } from '@theia/filesystem/lib/browser/file-dialog/file-dialog-service';
import { ElectronFileDialogService } from '@theia/filesystem/lib/electron-browser/file-dialog/electron-file-dialog-service';
import { RemoteService } from './remote-service';
@injectable()
export class RemoteElectronFileDialogService extends ElectronFileDialogService {
@inject(RemoteService) protected readonly remoteService: RemoteService;
override showOpenDialog(props: OpenFileDialogProps & { canSelectMany: true; }, folder?: FileStat | undefined): Promise<MaybeArray<URI> | undefined>;
override showOpenDialog(props: OpenFileDialogProps, folder?: FileStat | undefined): Promise<URI | undefined>;
override showOpenDialog(props: OpenFileDialogProps, folder?: FileStat): Promise<MaybeArray<URI> | undefined> | Promise<URI | undefined> {
if (this.remoteService.isConnected()) {
return DefaultFileDialogService.prototype.showOpenDialog.call(this, props, folder);
} else {
return super.showOpenDialog(props, folder);
}
}
override showSaveDialog(props: SaveFileDialogProps, folder?: FileStat | undefined): Promise<URI | undefined> {
if (this.remoteService.isConnected()) {
return DefaultFileDialogService.prototype.showSaveDialog.call(this, props, folder);
} else {
return super.showSaveDialog(props, folder);
}
}
}

View File

@@ -0,0 +1,148 @@
// *****************************************************************************
// Copyright (C) 2023 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { Command, CommandContribution, CommandRegistry, ContributionProvider, nls, QuickInputService, QuickPickInput } from '@theia/core';
import { FrontendApplicationContribution, StatusBar, StatusBarAlignment, StatusBarEntry } from '@theia/core/lib/browser';
import { inject, injectable, named, optional } from '@theia/core/shared/inversify';
import { RemoteStatus, RemoteStatusService } from '../electron-common/remote-status-service';
import { RemoteRegistry, RemoteRegistryContribution } from './remote-registry-contribution';
import { RemoteService } from './remote-service';
import { WindowService } from '@theia/core/lib/browser/window/window-service';
import { getLocalPort, getCurrentPort } from '@theia/core/lib/electron-browser/messaging/electron-local-ws-connection-source';
export namespace RemoteCommands {
export const REMOTE_SELECT: Command = {
id: 'remote.select'
};
export const REMOTE_DISCONNECT: Command = Command.toDefaultLocalizedCommand({
id: 'remote.disconnect',
label: 'Close Remote Connection',
});
}
@injectable()
export class RemoteFrontendContribution implements CommandContribution, FrontendApplicationContribution {
@inject(StatusBar)
protected readonly statusBar: StatusBar;
@inject(QuickInputService) @optional()
protected readonly quickInputService?: QuickInputService;
@inject(CommandRegistry)
protected readonly commandRegistry: CommandRegistry;
@inject(RemoteService)
protected readonly remoteService: RemoteService;
@inject(RemoteStatusService)
protected readonly remoteStatusService: RemoteStatusService;
@inject(WindowService)
protected readonly windowService: WindowService;
@inject(ContributionProvider) @named(RemoteRegistryContribution)
protected readonly remoteRegistryContributions: ContributionProvider<RemoteRegistryContribution>;
protected remoteRegistry = new RemoteRegistry();
async configure(): Promise<void> {
const port = getCurrentPort();
if (port) {
const status = await this.remoteStatusService.getStatus(Number(port));
await this.setStatusBar(status);
} else {
await this.setStatusBar({
alive: false
});
}
}
protected async setStatusBar(info: RemoteStatus): Promise<void> {
this.remoteService.setConnected(info.alive);
const entry: StatusBarEntry = {
alignment: StatusBarAlignment.LEFT,
command: RemoteCommands.REMOTE_SELECT.id,
backgroundColor: 'var(--theia-statusBarItem-remoteBackground)',
color: 'var(--theia-statusBarItem-remoteForeground)',
priority: 10000,
...(info.alive
? {
text: `$(codicon-remote) ${info.type}: ${info.name.length > 35 ? info.name.substring(0, 32) + '...' : info.name}`,
tooltip: nls.localizeByDefault('Editing on {0}', info.name),
} : {
text: '$(codicon-remote)',
tooltip: nls.localizeByDefault('Open a Remote Window'),
})
};
this.statusBar.setElement('remoteStatus', entry);
}
registerCommands(commands: CommandRegistry): void {
this.remoteRegistry.onDidRegisterCommand(([command, handler]) => {
commands.registerCommand(command, handler);
});
for (const contribution of this.remoteRegistryContributions.getContributions()) {
contribution.registerRemoteCommands(this.remoteRegistry);
}
commands.registerCommand(RemoteCommands.REMOTE_SELECT, {
execute: () => this.selectRemote()
});
commands.registerCommand(RemoteCommands.REMOTE_DISCONNECT, {
execute: () => this.disconnectRemote()
});
}
protected async disconnectRemote(): Promise<void> {
const localPort = getLocalPort();
if (localPort) {
const searchParams = new URLSearchParams(location.search);
const currentPort = searchParams.get('port');
this.remoteStatusService.connectionClosed(parseInt(currentPort ?? '0'));
this.windowService.reload({ search: { port: localPort } });
}
}
protected async selectRemote(): Promise<void> {
const commands = [...this.remoteRegistry.commands
.filter(command => this.commandRegistry.isVisible(command.id))];
if (this.remoteService.isConnected()) {
commands.push(RemoteCommands.REMOTE_DISCONNECT);
}
const quickPicks: QuickPickInput[] = [];
let previousCategory: string | undefined = undefined;
for (const command of commands) {
if (previousCategory !== command.category) {
quickPicks.push({
type: 'separator',
label: command.category
});
previousCategory = command.category;
}
quickPicks.push({
label: command.label!,
id: command.id
});
}
const selection = await this.quickInputService?.showQuickPick(quickPicks, {
placeholder: nls.localizeByDefault('Select an option to open a Remote Window')
});
if (selection) {
this.commandRegistry.executeCommand(selection.id!);
}
}
}

View File

@@ -0,0 +1,85 @@
// *****************************************************************************
// Copyright (C) 2023 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { bindContributionProvider, CommandContribution } from '@theia/core';
import { ContainerModule } from '@theia/core/shared/inversify';
import { bindViewContribution, FrontendApplicationContribution, isRemote, WidgetFactory } from '@theia/core/lib/browser';
import { RemoteSSHContribution } from './remote-ssh-contribution';
import { RemoteSSHConnectionProvider, RemoteSSHConnectionProviderPath } from '../electron-common/remote-ssh-connection-provider';
import { RemoteFrontendContribution } from './remote-frontend-contribution';
import { RemoteRegistryContribution } from './remote-registry-contribution';
import { RemoteService } from './remote-service';
import { RemoteStatusService, RemoteStatusServicePath } from '../electron-common/remote-status-service';
import { ElectronFileDialogService } from '@theia/filesystem/lib/electron-browser/file-dialog/electron-file-dialog-service';
import { RemoteElectronFileDialogService } from './remote-electron-file-dialog-service';
import { bindRemotePreferences } from '../electron-common/remote-preferences';
import { PortForwardingWidget, PORT_FORWARDING_WIDGET_ID } from './port-forwarding/port-forwarding-widget';
import { PortForwardingContribution } from './port-forwarding/port-forwading-contribution';
import { PortForwardingService } from './port-forwarding/port-forwarding-service';
import { RemotePortForwardingProvider, RemoteRemotePortForwardingProviderPath } from '../electron-common/remote-port-forwarding-provider';
import { ServiceConnectionProvider } from '@theia/core/lib/browser/messaging/service-connection-provider';
import '../../src/electron-browser/style/port-forwarding-widget.css';
import { UserStorageContribution } from '@theia/userstorage/lib/browser/user-storage-contribution';
import { RemoteUserStorageContribution } from './remote-user-storage-provider';
import { remoteFileSystemPath, RemoteFileSystemProxyFactory, RemoteFileSystemServer } from '@theia/filesystem/lib/common/remote-file-system-provider';
import { LocalEnvVariablesServer, LocalRemoteFileSystemProvider, LocalRemoteFileSytemServer } from './local-backend-services';
import { envVariablesPath, EnvVariablesServer } from '@theia/core/lib/common/env-variables';
export default new ContainerModule((bind, _, __, rebind) => {
bind(RemoteFrontendContribution).toSelf().inSingletonScope();
bind(FrontendApplicationContribution).toService(RemoteFrontendContribution);
bind(CommandContribution).toService(RemoteFrontendContribution);
bindContributionProvider(bind, RemoteRegistryContribution);
bind(RemoteSSHContribution).toSelf().inSingletonScope();
bind(RemoteRegistryContribution).toService(RemoteSSHContribution);
bindRemotePreferences(bind);
rebind(ElectronFileDialogService).to(RemoteElectronFileDialogService).inSingletonScope();
bind(RemoteService).toSelf().inSingletonScope();
bind(PortForwardingWidget).toSelf();
bind(WidgetFactory).toDynamicValue(context => ({
id: PORT_FORWARDING_WIDGET_ID,
createWidget: () => context.container.get<PortForwardingWidget>(PortForwardingWidget)
}));
bindViewContribution(bind, PortForwardingContribution);
bind(PortForwardingService).toSelf().inSingletonScope();
bind(RemoteSSHConnectionProvider).toDynamicValue(ctx =>
ServiceConnectionProvider.createLocalProxy<RemoteSSHConnectionProvider>(ctx.container, RemoteSSHConnectionProviderPath)).inSingletonScope();
bind(RemoteStatusService).toDynamicValue(ctx =>
ServiceConnectionProvider.createLocalProxy<RemoteStatusService>(ctx.container, RemoteStatusServicePath)).inSingletonScope();
bind(RemotePortForwardingProvider).toDynamicValue(ctx =>
ServiceConnectionProvider.createLocalProxy<RemotePortForwardingProvider>(ctx.container, RemoteRemotePortForwardingProviderPath)).inSingletonScope();
bind(LocalRemoteFileSytemServer).toDynamicValue(ctx =>
isRemote ?
ServiceConnectionProvider.createLocalProxy(ctx.container, remoteFileSystemPath, new RemoteFileSystemProxyFactory()) :
ctx.container.get(RemoteFileSystemServer));
bind(LocalEnvVariablesServer).toDynamicValue(ctx =>
isRemote ?
ServiceConnectionProvider.createLocalProxy<EnvVariablesServer>(ctx.container, envVariablesPath) :
ctx.container.get(EnvVariablesServer));
bind(LocalRemoteFileSystemProvider).toSelf().inSingletonScope();
rebind(UserStorageContribution).to(RemoteUserStorageContribution);
});

View File

@@ -0,0 +1,73 @@
// *****************************************************************************
// Copyright (C) 2023 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { Command, CommandHandler, Emitter, Event } from '@theia/core';
import { inject, injectable } from '@theia/core/shared/inversify';
import { WindowService, WindowReloadOptions } from '@theia/core/lib/browser/window/window-service';
export const RemoteRegistryContribution = Symbol('RemoteRegistryContribution');
export interface RemoteRegistryContribution {
registerRemoteCommands(registry: RemoteRegistry): void;
}
@injectable()
export abstract class AbstractRemoteRegistryContribution implements RemoteRegistryContribution {
@inject(WindowService)
protected readonly windowService: WindowService;
abstract registerRemoteCommands(registry: RemoteRegistry): void;
protected openRemote(port: string, newWindow: boolean, workspace?: string): void {
const searchParams = new URLSearchParams(location.search);
const localPort = searchParams.get('localPort') || searchParams.get('port');
const options: WindowReloadOptions = {
search: { port }
};
if (localPort) {
options.search!.localPort = localPort;
}
if (workspace) {
options.hash = workspace;
}
if (newWindow) {
this.windowService.openNewDefaultWindow(options);
} else {
this.windowService.reload(options);
}
}
}
export class RemoteRegistry {
protected _commands: Command[] = [];
protected onDidRegisterCommandEmitter = new Emitter<[Command, CommandHandler | undefined]>();
get commands(): readonly Command[] {
return this._commands;
}
get onDidRegisterCommand(): Event<[Command, CommandHandler | undefined]> {
return this.onDidRegisterCommandEmitter.event;
}
registerCommand(command: Command, handler?: CommandHandler): void {
this.onDidRegisterCommandEmitter.fire([command, handler]);
this._commands.push(command);
}
}

View File

@@ -0,0 +1,31 @@
// *****************************************************************************
// Copyright (C) 2023 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { injectable } from '@theia/core/shared/inversify';
@injectable()
export class RemoteService {
protected _connected: boolean;
isConnected(): boolean {
return this._connected;
}
setConnected(value: boolean): void {
this._connected = value;
}
}

View File

@@ -0,0 +1,185 @@
// *****************************************************************************
// Copyright (C) 2023 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { Command, MessageService, nls, QuickInputService, QuickPickInput } from '@theia/core';
import { inject, injectable } from '@theia/core/shared/inversify';
import { VariableResolverService } from '@theia/variable-resolver/lib/browser';
import { RemoteSSHConnectionProvider } from '../electron-common/remote-ssh-connection-provider';
import { AbstractRemoteRegistryContribution, RemoteRegistry } from './remote-registry-contribution';
import { RemotePreferences } from '../electron-common/remote-preferences';
import SSHConfig, { Directive } from 'ssh-config';
export namespace RemoteSSHCommands {
export const CONNECT: Command = Command.toLocalizedCommand({
id: 'remote.ssh.connect',
category: 'SSH',
label: 'Connect to Host...',
}, 'theia/remote/ssh/connect');
export const CONNECT_CURRENT_WINDOW: Command = Command.toLocalizedCommand({
id: 'remote.ssh.connectCurrentWindow',
category: 'SSH',
label: 'Connect Current Window to Host...',
}, 'theia/remote/ssh/connect');
export const CONNECT_CURRENT_WINDOW_TO_CONFIG_HOST: Command = Command.toLocalizedCommand({
id: 'remote.ssh.connectToConfigHost',
category: 'SSH',
label: 'Connect Current Window to Host in Config File...',
}, 'theia/remote/ssh/connectToConfigHost');
}
@injectable()
export class RemoteSSHContribution extends AbstractRemoteRegistryContribution {
@inject(QuickInputService)
protected readonly quickInputService: QuickInputService;
@inject(RemoteSSHConnectionProvider)
protected readonly sshConnectionProvider: RemoteSSHConnectionProvider;
@inject(MessageService)
protected readonly messageService: MessageService;
@inject(RemotePreferences)
protected readonly remotePreferences: RemotePreferences;
@inject(VariableResolverService)
protected readonly variableResolver: VariableResolverService;
registerRemoteCommands(registry: RemoteRegistry): void {
registry.registerCommand(RemoteSSHCommands.CONNECT, {
execute: () => this.connect(true)
});
registry.registerCommand(RemoteSSHCommands.CONNECT_CURRENT_WINDOW, {
execute: () => this.connect(false)
});
registry.registerCommand(RemoteSSHCommands.CONNECT_CURRENT_WINDOW_TO_CONFIG_HOST, {
execute: () => this.connectToConfigHost()
});
}
async getConfigFilePath(): Promise<string | undefined> {
const preference = this.remotePreferences['remote.ssh.configFile'];
return this.variableResolver.resolve(preference);
}
async connectToConfigHost(): Promise<void> {
const quickPicks: QuickPickInput[] = [];
const filePath = await this.getConfigFilePath();
if (!filePath) {
this.messageService.error(nls.localize('theia/remote/sshNoConfigPath', 'No SSH config path found.'));
return;
}
const sshConfig = await this.sshConnectionProvider.getSSHConfig(filePath);
const wildcardCheck = /[\?\*\%]/;
for (const record of sshConfig) {
// check if its a section and if it has a single value
if (!('config' in record) || !(typeof record.value === 'string')) {
continue;
}
if (record.param.toLowerCase() === 'host' && !wildcardCheck.test(record.value)) {
const rec: Record<string, string | string[]> = ((record.config)
.filter((entry): entry is Directive => entry.type === SSHConfig.DIRECTIVE))
.reduce(
(pv, item) => ({ ...pv, [item.param.toLowerCase()]: item.value }), { 'host': record.value }
);
const host = (rec.hostname || rec.host) + ':' + (rec.port || '22');
const user = rec.user || 'root';
quickPicks.push({
label: <string>rec.host,
id: user + '@' + host
});
}
}
if (quickPicks.length === 0) {
this.messageService.info(nls.localize('theia/remote/noConfigHosts', 'No SSH hosts found in the config file: ' + filePath));
return;
}
const selection = await this.quickInputService?.showQuickPick(quickPicks, {
placeholder: nls.localizeByDefault('Select an option to open a Remote Window')
});
if (selection?.id) {
try {
let [user, host] = selection.id.split('@', 2);
host = selection.label;
const remotePort = await this.sendSSHConnect(host, user);
this.openRemote(remotePort, false);
} catch (err) {
this.messageService.error(`${nls.localize('theia/remote/ssh/failure', 'Could not open SSH connection to remote.')} ${err.message ?? String(err)}`);
}
}
}
async connect(newWindow: boolean): Promise<void> {
let host: string | undefined;
let user: string | undefined;
host = await this.quickInputService.input({
title: nls.localize('theia/remote/ssh/enterHost', 'Enter SSH host name'),
placeHolder: nls.localize('theia/remote/ssh/hostPlaceHolder', 'E.g. hello@example.com')
});
if (!host) {
this.messageService.error(nls.localize('theia/remote/ssh/needsHost', 'Please enter a host name.'));
return;
}
if (host.includes('@')) {
const split = host.split('@');
user = split[0];
host = split[1];
}
if (!user) {
const configHost = await this.sshConnectionProvider.matchSSHConfigHost(host, undefined, await this.getConfigFilePath());
if (configHost) {
if (!user && configHost.user) {
user = <string>configHost.user;
}
}
}
if (!user) {
user = await this.quickInputService.input({
title: nls.localize('theia/remote/ssh/enterUser', 'Enter SSH user name'),
placeHolder: nls.localize('theia/remote/ssh/userPlaceHolder', 'E.g. hello')
});
}
if (!user) {
this.messageService.error(nls.localize('theia/remote/ssh/needsUser', 'Please enter a user name.'));
return;
}
try {
const remotePort = await this.sendSSHConnect(host!, user!);
this.openRemote(remotePort, newWindow);
} catch (err) {
this.messageService.error(`${nls.localize('theia/remote/ssh/failure', 'Could not open SSH connection to remote.')} ${err.message ?? String(err)}`);
}
}
async sendSSHConnect(host: string, user: string): Promise<string> {
return this.sshConnectionProvider.establishConnection({
host,
user,
nodeDownloadTemplate: this.remotePreferences['remote.nodeDownloadTemplate'],
customConfigFile: await this.getConfigFilePath()
});
}
}

View File

@@ -0,0 +1,64 @@
// *****************************************************************************
// Copyright (C) 2024 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { injectable, inject, postConstruct } from '@theia/core/shared/inversify';
import { FileService } from '@theia/filesystem/lib/browser/file-service';
import { FileSystemProvider } from '@theia/filesystem/lib/common/files';
import { UserStorageContribution } from '@theia/userstorage/lib/browser/user-storage-contribution';
import { RemoteStatusService } from '../electron-common/remote-status-service';
import { LocalEnvVariablesServer, LocalRemoteFileSystemProvider } from './local-backend-services';
import { Deferred } from '@theia/core/lib/common/promise-util';
import { URI } from '@theia/core';
import { EnvVariablesServer } from '@theia/core/lib/common/env-variables';
import { getCurrentPort } from '@theia/core/lib/electron-browser/messaging/electron-local-ws-connection-source';
/**
* This overide is to have remote connections still use settings, keymaps, etc. from the local machine.
*/
@injectable()
export class RemoteUserStorageContribution extends UserStorageContribution {
@inject(RemoteStatusService)
protected readonly remoteStatusService: RemoteStatusService;
@inject(LocalRemoteFileSystemProvider)
protected readonly localRemoteFileSystemProvider: LocalRemoteFileSystemProvider;
@inject(LocalEnvVariablesServer)
protected readonly localEnvironments: EnvVariablesServer;
isRemoteConnection: Deferred<boolean> = new Deferred();
@postConstruct()
protected init(): void {
const port = getCurrentPort();
if (port) {
this.remoteStatusService.getStatus(Number(port)).then(status => this.isRemoteConnection.resolve(status.alive));
}
}
protected override async getDelegate(service: FileService): Promise<FileSystemProvider> {
return await this.isRemoteConnection.promise ?
this.localRemoteFileSystemProvider
: service.activateProvider('file');
}
protected override async getCongigDirUri(): Promise<URI> {
return await this.isRemoteConnection.promise ?
new URI(await this.localEnvironments.getConfigDirUri())
: super.getCongigDirUri();
}
}

View File

@@ -0,0 +1,44 @@
/********************************************************************************
* Copyright (C) 2024 TypeFox and others.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License v. 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0.
*
* This Source Code may also be made available under the following Secondary
* Licenses when the conditions for such availability set forth in the Eclipse
* Public License v. 2.0 are satisfied: GNU General Public License, version 2
* with the GNU Classpath Exception which is available at
* https://www.gnu.org/software/classpath/license.html.
*
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
********************************************************************************/
.port-table {
width: 100%;
margin: calc(var(--theia-ui-padding) * 2);
table-layout: fixed;
}
.port-table-header {
text-align: left;
}
.forward-port-button {
margin-left: 0;
width: 100%;
}
.button-cell {
display: flex;
padding-right: var(--theia-ui-padding);
}
.forwarded-address:hover {
cursor: pointer;
text-decoration: underline;
}
.port-edit-input-error {
outline-color: var(--theia-inputValidation-errorBorder);
}

View File

@@ -0,0 +1,30 @@
// *****************************************************************************
// Copyright (C) 2024 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
export const RemoteRemotePortForwardingProviderPath = '/remote/port-forwarding';
export const RemotePortForwardingProvider = Symbol('RemoteSSHConnectionProvider');
export interface ForwardedPort {
port: number;
address?: string;
}
export interface RemotePortForwardingProvider {
forwardPort(connectionPort: number, portToForward: ForwardedPort): Promise<void>;
portRemoved(port: ForwardedPort): Promise<void>;
getForwardedPorts(): Promise<ForwardedPort[]>
}

View File

@@ -0,0 +1,68 @@
// *****************************************************************************
// Copyright (C) 2023 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { OS } from '@theia/core';
import { interfaces } from '@theia/core/shared/inversify';
import { PreferenceProxy } from '@theia/core/lib/common/preferences/preference-proxy';
import { nls } from '@theia/core/lib/common/nls';
import { PreferenceProxyFactory } from '@theia/core/lib/common/preferences/injectable-preference-proxy';
import { PreferenceContribution, PreferenceSchema } from '@theia/core/lib/common/preferences/preference-schema';
const nodeDownloadTemplateParts = [
nls.localize('theia/remote/nodeDownloadTemplateVersion', '`{version}` for the used node version'),
nls.localize('theia/remote/nodeDownloadTemplateOS', '`{os}` for the remote operating system. Either `win`, `linux` or `darwin`.'),
nls.localize('theia/remote/nodeDownloadTemplateArch', '`{arch}` for the remote system architecture.'),
nls.localize('theia/remote/nodeDownloadTemplateExt', '`{ext}` for the file extension. Either `zip`, `tar.xz` or `tar.xz`, depending on the operating system.')
];
export const RemotePreferenceSchema: PreferenceSchema = {
properties: {
'remote.nodeDownloadTemplate': {
type: 'string',
default: '',
markdownDescription: nls.localize(
'theia/remote/nodeDownloadTemplate',
'Controls the template used to download the node.js binaries for the remote backend. Points to the official node.js website by default. Uses multiple placeholders:'
) + '\n- ' + nodeDownloadTemplateParts.join('\n- ')
},
'remote.ssh.configFile': {
type: 'string',
default: OS.backend.isWindows ? '${env:USERPROFILE}\\.ssh\\config' : '${env:HOME}/.ssh/config',
markdownDescription: nls.localize(
'theia/remote/ssh/configFile',
'Remote SSH Config file'
)
},
}
};
export interface RemoteConfiguration {
'remote.nodeDownloadTemplate': string;
'remote.ssh.configFile': string;
}
export const RemotePreferenceContribution = Symbol('RemotePreferenceContribution');
export const RemotePreferences = Symbol('GettingStartedPreferences');
export type RemotePreferences = PreferenceProxy<RemoteConfiguration>;
export function bindRemotePreferences(bind: interfaces.Bind): void {
bind(RemotePreferences).toDynamicValue(ctx => {
const factory = ctx.container.get<PreferenceProxyFactory>(PreferenceProxyFactory);
return factory(RemotePreferenceSchema);
}).inSingletonScope();
bind(RemotePreferenceContribution).toConstantValue({ schema: RemotePreferenceSchema });
bind(PreferenceContribution).toService(RemotePreferenceContribution);
}

View File

@@ -0,0 +1,36 @@
// *****************************************************************************
// Copyright (C) 2023 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import * as SshConfig from 'ssh-config';
export const RemoteSSHConnectionProviderPath = '/remote/ssh';
export const RemoteSSHConnectionProvider = Symbol('RemoteSSHConnectionProvider');
export interface RemoteSSHConnectionProviderOptions {
user: string;
host: string;
nodeDownloadTemplate?: string;
customConfigFile?: string;
}
export type SSHConfig = Array<SshConfig.Line>;
export interface RemoteSSHConnectionProvider {
establishConnection(options: RemoteSSHConnectionProviderOptions): Promise<string>;
getSSHConfig(customConfigFile?: string): Promise<SSHConfig>;
matchSSHConfigHost(host: string, user?: string, customConfigFile?: string): Promise<Record<string, string | string[]> | undefined>;
}

View File

@@ -0,0 +1,37 @@
// *****************************************************************************
// Copyright (C) 2023 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
export type RemoteStatus = RemoteConnectedStatus | RemoteDisconnectedStatus;
export interface RemoteDisconnectedStatus {
alive: false;
}
export interface RemoteConnectedStatus {
alive: true;
type: string;
name: string;
}
export const RemoteStatusServicePath = '/remote/status';
export const RemoteStatusService = Symbol('RemoteStatusService');
export interface RemoteStatusService {
getStatus(localPort: number): Promise<RemoteStatus>;
connectionClosed(localPort: number): Promise<void>;
}

View File

@@ -0,0 +1,45 @@
// *****************************************************************************
// Copyright (C) 2023 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { CliContribution } from '@theia/core/lib/node';
import { injectable } from '@theia/core/shared/inversify';
import { Arguments, Argv } from '@theia/core/shared/yargs';
import { BackendRemoteService } from '@theia/core/lib/node/remote/backend-remote-service';
export const REMOTE_START = 'remote';
@injectable()
export class BackendRemoteServiceImpl extends BackendRemoteService implements CliContribution {
protected isRemote: boolean = false;
configure(conf: Argv): void {
conf.option(REMOTE_START, {
description: 'Starts the server as an endpoint for a remote connection (i.e. through SSH)',
type: 'boolean',
default: false
});
}
setArguments(args: Arguments): void {
this.isRemote = Boolean(args[REMOTE_START]);
}
override isRemoteServer(): boolean {
return this.isRemote;
}
}

View File

@@ -0,0 +1,89 @@
// *****************************************************************************
// Copyright (C) 2023 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { ContainerModule } from '@theia/core/shared/inversify';
import { BackendApplicationContribution, CliContribution } from '@theia/core/lib/node';
import { RemoteConnectionService } from './remote-connection-service';
import { RemoteProxyServerProvider } from './remote-proxy-server-provider';
import { RemoteConnectionSocketProvider } from './remote-connection-socket-provider';
import { ConnectionContainerModule } from '@theia/core/lib/node/messaging/connection-container-module';
import { RemoteSSHConnectionProvider, RemoteSSHConnectionProviderPath } from '../electron-common/remote-ssh-connection-provider';
import { RemoteSSHConnectionProviderImpl } from './ssh/remote-ssh-connection-provider';
import { SSHIdentityFileCollector } from './ssh/ssh-identity-file-collector';
import { RemoteCopyService } from './setup/remote-copy-service';
import { RemoteSetupService } from './setup/remote-setup-service';
import { RemoteNativeDependencyService } from './setup/remote-native-dependency-service';
import { BackendRemoteServiceImpl } from './backend-remote-service-impl';
import { BackendRemoteService } from '@theia/core/lib/node/remote/backend-remote-service';
import { RemoteNodeSetupService } from './setup/remote-node-setup-service';
import { RemotePosixScriptStrategy, RemoteSetupScriptService, RemoteWindowsScriptStrategy } from './setup/remote-setup-script-service';
import { RemoteStatusService, RemoteStatusServicePath } from '../electron-common/remote-status-service';
import { RemoteStatusServiceImpl } from './remote-status-service';
import { ConnectionHandler, RpcConnectionHandler, bindContributionProvider } from '@theia/core';
import { RemoteCopyRegistryImpl } from './setup/remote-copy-contribution';
import { RemoteCopyContribution } from '@theia/core/lib/node/remote/remote-copy-contribution';
import { MainCopyContribution } from './setup/main-copy-contribution';
import { RemoteNativeDependencyContribution } from './setup/remote-native-dependency-contribution';
import { AppNativeDependencyContribution } from './setup/app-native-dependency-contribution';
import { RemotePortForwardingProviderImpl } from './remote-port-forwarding-provider';
import { RemotePortForwardingProvider, RemoteRemotePortForwardingProviderPath } from '../electron-common/remote-port-forwarding-provider';
import { bindRemotePreferences } from '../electron-common/remote-preferences';
export const remoteConnectionModule = ConnectionContainerModule.create(({ bind, bindBackendService }) => {
bind(RemoteSSHConnectionProviderImpl).toSelf().inSingletonScope();
bind(RemoteSSHConnectionProvider).toService(RemoteSSHConnectionProviderImpl);
bindBackendService(RemoteSSHConnectionProviderPath, RemoteSSHConnectionProvider);
bind(RemotePortForwardingProviderImpl).toSelf().inSingletonScope();
bind(RemotePortForwardingProvider).toService(RemotePortForwardingProviderImpl);
bindBackendService(RemoteRemotePortForwardingProviderPath, RemotePortForwardingProvider);
});
export default new ContainerModule((bind, _unbind, _isBound, rebind) => {
bind(RemoteProxyServerProvider).toSelf().inSingletonScope();
bind(RemoteConnectionSocketProvider).toSelf().inSingletonScope();
bind(RemoteConnectionService).toSelf().inSingletonScope();
bind(BackendApplicationContribution).toService(RemoteConnectionService);
bind(RemoteStatusServiceImpl).toSelf().inSingletonScope();
bind(RemoteStatusService).toService(RemoteStatusServiceImpl);
bind(ConnectionHandler).toDynamicValue(
ctx => new RpcConnectionHandler(RemoteStatusServicePath, () => ctx.container.get(RemoteStatusService))
).inSingletonScope();
bind(RemoteCopyService).toSelf().inSingletonScope();
bind(RemoteSetupService).toSelf().inSingletonScope();
bind(RemoteNodeSetupService).toSelf().inSingletonScope();
bind(RemoteWindowsScriptStrategy).toSelf().inSingletonScope();
bind(RemotePosixScriptStrategy).toSelf().inSingletonScope();
bind(RemoteSetupScriptService).toSelf().inSingletonScope();
bind(RemoteNativeDependencyService).toSelf().inSingletonScope();
bind(RemoteCopyRegistryImpl).toSelf().inSingletonScope();
bindContributionProvider(bind, RemoteCopyContribution);
bindContributionProvider(bind, RemoteNativeDependencyContribution);
bind(MainCopyContribution).toSelf().inSingletonScope();
bind(RemoteCopyContribution).toService(MainCopyContribution);
bind(AppNativeDependencyContribution).toSelf().inSingletonScope();
bind(RemoteNativeDependencyContribution).toService(AppNativeDependencyContribution);
bind(ConnectionContainerModule).toConstantValue(remoteConnectionModule);
bind(BackendRemoteServiceImpl).toSelf().inSingletonScope();
rebind(BackendRemoteService).toService(BackendRemoteServiceImpl);
bind(CliContribution).toService(BackendRemoteServiceImpl);
bind(SSHIdentityFileCollector).toSelf().inSingletonScope();
bindRemotePreferences(bind);
});

View File

@@ -0,0 +1,60 @@
// *****************************************************************************
// Copyright (C) 2023 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { inject, injectable } from '@theia/core/shared/inversify';
import { RemoteConnection } from './remote-types';
import { Disposable } from '@theia/core';
import { RemoteCopyService } from './setup/remote-copy-service';
import { BackendApplicationContribution } from '@theia/core/lib/node';
import { RemoteSetupService } from './setup/remote-setup-service';
@injectable()
export class RemoteConnectionService implements BackendApplicationContribution {
@inject(RemoteCopyService)
protected readonly copyService: RemoteCopyService;
// Workaround for the fact that connection scoped services cannot directly inject these services.
@inject(RemoteSetupService)
protected readonly remoteSetupService: RemoteSetupService;
protected readonly connections = new Map<string, RemoteConnection>();
getConnection(id: string): RemoteConnection | undefined {
return this.connections.get(id);
}
getConnectionFromPort(port: number): RemoteConnection | undefined {
return Array.from(this.connections.values()).find(connection => connection.localPort === port);
}
register(connection: RemoteConnection): Disposable {
this.connections.set(connection.id, connection);
return Disposable.create(() => {
this.connections.delete(connection.id);
});
}
onStop(): void {
for (const connection of this.connections.values()) {
if (connection.disposeSync) {
connection.disposeSync();
} else {
connection.dispose();
};
}
}
}

View File

@@ -0,0 +1,34 @@
// *****************************************************************************
// Copyright (C) 2023 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { injectable } from '@theia/core/shared/inversify';
import { io, Socket } from 'socket.io-client';
export interface RemoteProxySocketProviderOptions {
port: number;
path: string;
}
@injectable()
export class RemoteConnectionSocketProvider {
getProxySocket(options: RemoteProxySocketProviderOptions): Socket {
const socket = io(`ws://localhost:${options.port}${options.path}`);
socket.connect();
return socket;
}
}

View File

@@ -0,0 +1,66 @@
// *****************************************************************************
// Copyright (C) 2024 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { inject, injectable } from '@theia/core/shared/inversify';
import { ForwardedPort, RemotePortForwardingProvider } from '../electron-common/remote-port-forwarding-provider';
import { createServer, Server } from 'net';
import { RemoteConnectionService } from './remote-connection-service';
import { RemoteConnection } from './remote-types';
interface ForwardInfo {
connection: RemoteConnection
port: ForwardedPort
server: Server
}
@injectable()
export class RemotePortForwardingProviderImpl implements RemotePortForwardingProvider {
@inject(RemoteConnectionService)
protected readonly connectionService: RemoteConnectionService;
protected static forwardedPorts: ForwardInfo[] = [];
async forwardPort(connectionPort: number, portToForward: ForwardedPort): Promise<void> {
const currentConnection = this.connectionService.getConnectionFromPort(connectionPort);
if (!currentConnection) {
throw new Error(`No connection found for port ${connectionPort}`);
}
const server = createServer(socket => {
currentConnection?.forwardOut(socket, portToForward.port);
}).listen(portToForward.port, portToForward.address);
currentConnection.onDidDisconnect(() => {
this.portRemoved(portToForward);
});
RemotePortForwardingProviderImpl.forwardedPorts.push({ connection: currentConnection, port: portToForward, server });
}
async portRemoved(forwardedPort: ForwardedPort): Promise<void> {
const forwardInfo = RemotePortForwardingProviderImpl.forwardedPorts.find(info => info.port.port === forwardedPort.port);
if (forwardInfo) {
forwardInfo.server.close();
RemotePortForwardingProviderImpl.forwardedPorts.splice(RemotePortForwardingProviderImpl.forwardedPorts.indexOf(forwardInfo), 1);
}
}
async getForwardedPorts(): Promise<ForwardedPort[]> {
return Array.from(RemotePortForwardingProviderImpl.forwardedPorts)
.map(forwardInfo => ({ ...forwardInfo.port, editing: false }));
}
}

View File

@@ -0,0 +1,37 @@
// *****************************************************************************
// Copyright (C) 2023 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { Deferred } from '@theia/core/lib/common/promise-util';
import { injectable } from '@theia/core/shared/inversify';
import * as net from 'net';
@injectable()
export class RemoteProxyServerProvider {
async getProxyServer(callback?: (socket: net.Socket) => void): Promise<net.Server> {
const deferred = new Deferred();
const proxy = net.createServer(socket => {
callback?.(socket);
}).listen(0, () => {
deferred.resolve();
});
await deferred.promise;
return proxy;
}
}

View File

@@ -0,0 +1,48 @@
// *****************************************************************************
// Copyright (C) 2023 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { inject, injectable } from '@theia/core/shared/inversify';
import { RemoteStatus, RemoteStatusService } from '../electron-common/remote-status-service';
import { RemoteConnectionService } from './remote-connection-service';
@injectable()
export class RemoteStatusServiceImpl implements RemoteStatusService {
@inject(RemoteConnectionService)
protected remoteConnectionService: RemoteConnectionService;
async getStatus(localPort: number): Promise<RemoteStatus> {
const connection = this.remoteConnectionService.getConnectionFromPort(localPort);
if (connection) {
return {
alive: true,
name: connection.name,
type: connection.type
};
} else {
return {
alive: false
};
}
}
async connectionClosed(localPort: number): Promise<void> {
const connection = this.remoteConnectionService.getConnectionFromPort(localPort);
if (connection) {
connection.dispose();
}
}
}

View File

@@ -0,0 +1,69 @@
// *****************************************************************************
// Copyright (C) 2023 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { Disposable, Event } from '@theia/core';
import * as net from 'net';
export type RemoteStatusReport = (message: string) => void;
export interface ExpressLayer {
name: string
regexp: RegExp
handle: Function
path?: string
}
export interface RemoteExecOptions {
env?: NodeJS.ProcessEnv;
}
export interface RemoteExecResult {
stdout: string;
stderr: string;
}
export type RemoteExecTester = (stdout: string, stderr: string) => boolean;
export interface RemoteConnection extends Disposable {
id: string;
name: string;
type: string;
localPort: number;
remotePort: number;
onDidDisconnect: Event<void>;
forwardOut(socket: net.Socket, port?: number): void;
/**
* execute a single command on the remote machine
*/
exec(cmd: string, args?: string[], options?: RemoteExecOptions): Promise<RemoteExecResult>;
/**
* execute a command on the remote machine and wait for a specific output
* @param tester function which returns true if the output is as expected
*/
execPartial(cmd: string, tester: RemoteExecTester, args?: string[], options?: RemoteExecOptions): Promise<RemoteExecResult>;
/**
* copy files from local to remote
*/
copy(localPath: string | Buffer | NodeJS.ReadableStream, remotePath: string): Promise<void>;
/**
* used for disposing when theia is shutting down
*/
disposeSync?(): void;
}

View File

@@ -0,0 +1,48 @@
// *****************************************************************************
// Copyright (C) 2023 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { injectable } from '@theia/core/shared/inversify';
import { RemoteNativeDependencyContribution, DownloadOptions, DependencyDownload } from './remote-native-dependency-contribution';
import { RemotePlatform } from '@theia/core/lib/node/remote/remote-cli-contribution';
import { OS } from '@theia/core';
@injectable()
export class AppNativeDependencyContribution implements RemoteNativeDependencyContribution {
appDownloadUrlBase = 'https://github.com/eclipse-theia/theia/releases/download';
protected getDefaultURLForFile(remotePlatform: RemotePlatform, theiaVersion: string): string {
if (remotePlatform.arch !== 'x64') {
throw new Error(`Unsupported remote architecture '${remotePlatform.arch}'. Remote support is only available for x64 architectures.`);
}
let platform: string;
if (remotePlatform.os === OS.Type.Windows) {
platform = 'win32';
} else if (remotePlatform.os === OS.Type.OSX) {
platform = 'darwin';
} else {
platform = 'linux';
}
return `${this.appDownloadUrlBase}/v${theiaVersion}/native-dependencies-${platform}-${remotePlatform.arch}.zip`;
}
async download(options: DownloadOptions): Promise<DependencyDownload> {
return {
buffer: await options.download(this.getDefaultURLForFile(options.remotePlatform, options.theiaVersion)),
archive: 'zip'
};
}
}

View File

@@ -0,0 +1,28 @@
// *****************************************************************************
// Copyright (C) 2023 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { injectable } from '@theia/core/shared/inversify';
import { RemoteCopyContribution, RemoteCopyRegistry } from '@theia/core/lib/node/remote/remote-copy-contribution';
@injectable()
export class MainCopyContribution implements RemoteCopyContribution {
async copy(registry: RemoteCopyRegistry): Promise<void> {
registry.file('package.json');
await registry.glob('lib/backend/**/*.js');
await registry.directory('lib/frontend');
await registry.directory('lib/webview');
}
}

View File

@@ -0,0 +1,74 @@
// *****************************************************************************
// Copyright (C) 2023 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { ApplicationPackage } from '@theia/core/shared/@theia/application-package';
import { inject, injectable } from '@theia/core/shared/inversify';
import { RemoteCopyRegistry, RemoteFile, RemoteCopyOptions } from '@theia/core/lib/node/remote/remote-copy-contribution';
import { glob as globCallback } from 'glob';
import { promisify } from 'util';
import * as path from 'path';
const promiseGlob = promisify(globCallback);
@injectable()
export class RemoteCopyRegistryImpl implements RemoteCopyRegistry {
@inject(ApplicationPackage)
protected readonly applicationPackage: ApplicationPackage;
protected readonly files: RemoteFile[] = [];
getFiles(): RemoteFile[] {
return this.files.slice();
}
async glob(pattern: string, target?: string): Promise<void> {
return this.doGlob(pattern, this.applicationPackage.projectPath, target);
}
async doGlob(pattern: string, cwd: string, target?: string): Promise<void> {
const projectPath = this.applicationPackage.projectPath;
const globResult = await promiseGlob(pattern, { cwd, nodir: true });
for (const file of globResult) {
const targetFile = this.withTarget(file, target);
this.files.push({
path: path.relative(projectPath, path.resolve(cwd, file)),
target: targetFile
});
}
}
file(file: string, target?: string, options?: RemoteCopyOptions): void {
const targetFile = this.withTarget(file, target);
this.files.push({
path: file,
target: targetFile,
options
});
}
async directory(dir: string, target?: string): Promise<void> {
let absoluteDir = dir;
if (!path.isAbsolute(absoluteDir)) {
absoluteDir = path.join(this.applicationPackage.projectPath, dir);
}
return this.doGlob('**/*', absoluteDir, target ?? dir);
}
protected withTarget(file: string, target?: string): string {
return target ? path.join(target, file) : file;
}
}

View File

@@ -0,0 +1,116 @@
// *****************************************************************************
// Copyright (C) 2023 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import * as archiver from 'archiver';
import * as path from 'path';
import * as fs from 'fs';
import * as os from 'os';
import { ApplicationPackage } from '@theia/core/shared/@theia/application-package';
import { inject, injectable, named } from '@theia/core/shared/inversify';
import { RemoteConnection } from '../remote-types';
import { RemotePlatform } from '@theia/core/lib/node/remote/remote-cli-contribution';
import { RemoteNativeDependencyService } from './remote-native-dependency-service';
import { ContributionProvider } from '@theia/core';
import { RemoteCopyRegistryImpl } from './remote-copy-contribution';
import { RemoteCopyContribution, RemoteFile } from '@theia/core/lib/node/remote/remote-copy-contribution';
@injectable()
export class RemoteCopyService {
@inject(ApplicationPackage)
protected readonly applicationPackage: ApplicationPackage;
@inject(RemoteCopyRegistryImpl)
protected readonly copyRegistry: RemoteCopyRegistryImpl;
@inject(RemoteNativeDependencyService)
protected readonly nativeDependencyService: RemoteNativeDependencyService;
@inject(ContributionProvider) @named(RemoteCopyContribution)
protected readonly copyContributions: ContributionProvider<RemoteCopyContribution>;
protected initialized = false;
async copyToRemote(remote: RemoteConnection, remotePlatform: RemotePlatform, destination: string): Promise<void> {
const zipName = path.basename(destination);
const projectPath = this.applicationPackage.projectPath;
const tempDir = await this.getTempDir();
const zipPath = path.join(tempDir, zipName);
const files = await this.getFiles(remotePlatform, tempDir);
// We stream to a file here and then copy it because it is faster
// Copying files via sftp is 4x times faster compared to readable streams
const stream = fs.createWriteStream(zipPath);
const archive = archiver('tar', {
gzip: true
});
archive.pipe(stream);
for (const file of files) {
const filePath = path.isAbsolute(file.path)
? file.path
: path.join(projectPath, file.path);
archive.file(filePath, {
name: file.target,
mode: file.options?.mode
});
}
await archive.finalize();
await remote.copy(zipPath, destination);
await fs.promises.rm(tempDir, {
recursive: true,
force: true
});
}
protected async getFiles(remotePlatform: RemotePlatform, tempDir: string): Promise<RemoteFile[]> {
const [localFiles, nativeDependencies] = await Promise.all([
this.loadCopyContributions(),
this.loadNativeDependencies(remotePlatform, tempDir)
]);
return [...localFiles, ...nativeDependencies];
}
protected async loadCopyContributions(): Promise<RemoteFile[]> {
if (this.initialized) {
return this.copyRegistry.getFiles();
}
await Promise.all(this.copyContributions.getContributions()
.map(copyContribution => copyContribution.copy(this.copyRegistry)));
this.initialized = true;
return this.copyRegistry.getFiles();
}
protected async loadNativeDependencies(remotePlatform: RemotePlatform, tempDir: string): Promise<RemoteFile[]> {
const dependencyFiles = await this.nativeDependencyService.downloadDependencies(remotePlatform, tempDir);
return dependencyFiles.map(file => ({
path: file.path,
target: file.target,
options: {
mode: file.mode
}
}));
}
protected async getTempDir(): Promise<string> {
const dir = path.join(os.tmpdir(), 'theia-remote-');
const tempDir = await fs.promises.mkdtemp(dir);
return tempDir;
}
protected async getRemoteDownloadLocation(): Promise<string | undefined> {
return undefined;
}
}

View File

@@ -0,0 +1,63 @@
// *****************************************************************************
// Copyright (C) 2023 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { isObject } from '@theia/core';
import { RequestOptions } from '@theia/core/shared/@theia/request';
import { RemotePlatform } from '@theia/core/lib/node/remote/remote-cli-contribution';
export interface FileDependencyResult {
path: string;
mode?: number;
}
export type DependencyDownload = FileDependencyDownload | DirectoryDependencyDownload;
export interface FileDependencyDownload {
file: FileDependencyResult
buffer: Buffer
}
export namespace FileDependencyResult {
export function is(item: unknown): item is FileDependencyDownload {
return isObject(item) && 'buffer' in item && 'file' in item;
}
}
export interface DirectoryDependencyDownload {
archive: 'tar' | 'zip' | 'tgz'
buffer: Buffer
}
export namespace DirectoryDependencyDownload {
export function is(item: unknown): item is DirectoryDependencyDownload {
return isObject(item) && 'buffer' in item && 'archive' in item;
}
}
export interface DownloadOptions {
remotePlatform: RemotePlatform
theiaVersion: string;
download: (requestInfo: string | RequestOptions) => Promise<Buffer>
}
export const RemoteNativeDependencyContribution = Symbol('RemoteNativeDependencyContribution');
/**
* contribution used for downloading prebuild native dependency when connecting to a remote machine with a different system
*/
export interface RemoteNativeDependencyContribution {
download(options: DownloadOptions): Promise<DependencyDownload>;
}

View File

@@ -0,0 +1,115 @@
// *****************************************************************************
// Copyright (C) 2023 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { ContributionProvider, THEIA_VERSION } from '@theia/core';
import { inject, injectable, named } from '@theia/core/shared/inversify';
import { RequestContext, RequestService, RequestOptions } from '@theia/core/shared/@theia/request';
import * as decompress from 'decompress';
import * as path from 'path';
import * as fs from 'fs/promises';
import { DependencyDownload, DirectoryDependencyDownload, RemoteNativeDependencyContribution } from './remote-native-dependency-contribution';
import { RemotePlatform } from '@theia/core/lib/node/remote/remote-cli-contribution';
const decompressTar = require('decompress-tar');
const decompressTargz = require('decompress-targz');
const decompressUnzip = require('decompress-unzip');
export const DEFAULT_HTTP_OPTIONS = {
method: 'GET',
headers: {
Accept: 'application/octet-stream'
},
};
export interface NativeDependencyFile {
path: string;
target: string;
mode?: number;
}
@injectable()
export class RemoteNativeDependencyService {
@inject(ContributionProvider) @named(RemoteNativeDependencyContribution)
protected nativeDependencyContributions: ContributionProvider<RemoteNativeDependencyContribution>;
@inject(RequestService)
protected requestService: RequestService;
async downloadDependencies(remotePlatform: RemotePlatform, directory: string): Promise<NativeDependencyFile[]> {
const contributionResults = await Promise.all(this.nativeDependencyContributions.getContributions()
.map(async contribution => {
const result = await contribution.download({
remotePlatform,
theiaVersion: THEIA_VERSION,
download: requestInfo => this.downloadDependency(requestInfo)
});
const dependency = await this.storeDependency(result, directory);
return dependency;
}));
return contributionResults.flat();
}
protected async downloadDependency(downloadURI: string | RequestOptions): Promise<Buffer> {
const options = typeof downloadURI === 'string'
? { url: downloadURI, ...DEFAULT_HTTP_OPTIONS }
: { ...DEFAULT_HTTP_OPTIONS, ...downloadURI };
const req = await this.requestService.request(options);
if (RequestContext.isSuccess(req)) {
if (typeof req.buffer === 'string') {
return Buffer.from(req.buffer, 'utf8');
} else {
return Buffer.from(req.buffer);
}
} else {
throw new Error('Server error while downloading native dependency from: ' + options.url);
}
}
protected async storeDependency(dependency: DependencyDownload, directory: string): Promise<NativeDependencyFile[]> {
if (DirectoryDependencyDownload.is(dependency)) {
const archiveBuffer = dependency.buffer;
const plugins: unknown[] = [];
if (dependency.archive === 'tar') {
plugins.push(decompressTar());
} else if (dependency.archive === 'tgz') {
plugins.push(decompressTargz());
} else if (dependency.archive === 'zip') {
plugins.push(decompressUnzip());
}
const files = await decompress(archiveBuffer, directory, { plugins });
const result: NativeDependencyFile[] = await Promise.all(files.map(async file => {
const localPath = path.join(directory, file.path);
return {
path: localPath,
target: file.path,
mode: file.mode
};
}));
return result;
} else {
const fileName = path.basename(dependency.file.path);
const localPath = path.join(directory, fileName);
await fs.writeFile(localPath, dependency.buffer);
return [{
path: localPath,
target: dependency.file.path,
mode: dependency.file.mode
}];
}
}
}

View File

@@ -0,0 +1,123 @@
// *****************************************************************************
// Copyright (C) 2023 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import * as path from 'path';
import * as fs from '@theia/core/shared/fs-extra';
import * as os from 'os';
import { inject, injectable } from '@theia/core/shared/inversify';
import { RequestService } from '@theia/core/shared/@theia/request';
import { RemoteSetupScriptService } from './remote-setup-script-service';
import { RemotePlatform } from '@theia/core/lib/node/remote/remote-cli-contribution';
import { OS } from '@theia/core';
/**
* The current node version that Theia recommends.
*
* Native dependencies are compiled against this version.
*/
export const REMOTE_NODE_VERSION = '22.20.0';
@injectable()
export class RemoteNodeSetupService {
@inject(RequestService)
protected readonly requestService: RequestService;
@inject(RemoteSetupScriptService)
protected readonly scriptService: RemoteSetupScriptService;
getNodeDirectoryName(platform: RemotePlatform): string {
return `node-v${REMOTE_NODE_VERSION}-${this.getPlatformName(platform)}-${platform.arch}`;
}
protected getPlatformName(platform: RemotePlatform): string {
let platformId: string;
if (platform.os === OS.Type.Windows) {
platformId = 'win';
} else if (platform.os === OS.Type.OSX) {
platformId = 'darwin';
} else {
platformId = 'linux';
}
return platformId;
}
protected validatePlatform(platform: RemotePlatform): void {
if (platform.os === OS.Type.Windows && !platform.arch.match(/^x(64|86)$/)) {
this.throwPlatformError(platform, 'x64 and x86');
} else if (platform.os === OS.Type.Linux && !platform.arch.match(/^(x64|armv7l|arm64)$/)) {
this.throwPlatformError(platform, 'x64, armv7l and arm64');
} else if (platform.os === OS.Type.OSX && !platform.arch.match(/^(x64|arm64)$/)) {
this.throwPlatformError(platform, 'x64 and arm64');
}
}
protected throwPlatformError(platform: RemotePlatform, supportedArch: string): never {
throw new Error(`Invalid architecture for ${platform.os}: '${platform.arch}'. Only ${supportedArch} are supported.`);
}
protected getNodeFileExtension(platform: RemotePlatform): string {
let fileExtension: string;
if (platform.os === OS.Type.Windows) {
fileExtension = 'zip';
} else if (platform.os === OS.Type.OSX) {
fileExtension = 'tar.gz';
} else {
fileExtension = 'tar.xz';
}
return fileExtension;
}
getNodeFileName(platform: RemotePlatform): string {
return `${this.getNodeDirectoryName(platform)}.${this.getNodeFileExtension(platform)}`;
}
async downloadNode(platform: RemotePlatform, downloadTemplate?: string): Promise<string> {
this.validatePlatform(platform);
const fileName = this.getNodeFileName(platform);
const tmpdir = os.tmpdir();
const localPath = path.join(tmpdir, fileName);
if (!await fs.pathExists(localPath)) {
const downloadPath = this.getDownloadPath(platform, downloadTemplate);
const downloadResult = await this.requestService.request({
url: downloadPath
});
await fs.writeFile(localPath, downloadResult.buffer);
}
return localPath;
}
generateDownloadScript(platform: RemotePlatform, targetPath: string, downloadTemplate?: string): string {
this.validatePlatform(platform);
const fileName = this.getNodeFileName(platform);
const downloadPath = this.getDownloadPath(platform, downloadTemplate);
const zipPath = this.scriptService.joinPath(platform, targetPath, fileName);
const download = this.scriptService.downloadFile(platform, downloadPath, zipPath);
const unzip = this.scriptService.unzip(platform, zipPath, targetPath);
return this.scriptService.joinScript(platform, download, unzip);
}
protected getDownloadPath(platform: RemotePlatform, downloadTemplate?: string): string {
const template = downloadTemplate || 'https://nodejs.org/dist/v{version}/node-v{version}-{os}-{arch}.{ext}';
const downloadPath = template
.replace(/{version}/g, REMOTE_NODE_VERSION)
.replace(/{os}/g, this.getPlatformName(platform))
.replace(/{arch}/g, platform.arch)
.replace(/{ext}/g, this.getNodeFileExtension(platform));
return downloadPath;
}
}

View File

@@ -0,0 +1,146 @@
// *****************************************************************************
// Copyright (C) 2023 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { OS } from '@theia/core';
import { inject, injectable } from '@theia/core/shared/inversify';
import { RemotePlatform } from '@theia/core/lib/node/remote/remote-cli-contribution';
export interface RemoteScriptStrategy {
exec(): string;
downloadFile(url: string, output: string): string;
unzip(file: string, directory: string): string;
mkdir(path: string): string;
home(): string;
joinPath(...segments: string[]): string;
joinScript(...segments: string[]): string;
}
@injectable()
export class RemoteWindowsScriptStrategy implements RemoteScriptStrategy {
home(): string {
return 'PowerShell -Command $HOME';
}
exec(): string {
return 'PowerShell -Command';
}
downloadFile(url: string, output: string): string {
return `PowerShell -Command Invoke-WebRequest -Uri "${url}" -OutFile ${output}`;
}
unzip(file: string, directory: string): string {
return `tar -xf "${file}" -C "${directory}"`;
}
mkdir(path: string): string {
return `PowerShell -Command New-Item -Force -itemType Directory -Path "${path}"`;
}
joinPath(...segments: string[]): string {
return segments.join('\\');
}
joinScript(...segments: string[]): string {
return segments.join('\r\n');
}
}
@injectable()
export class RemotePosixScriptStrategy implements RemoteScriptStrategy {
home(): string {
return 'eval echo ~';
}
exec(): string {
return 'sh -c';
}
downloadFile(url: string, output: string): string {
return `
if [ "$(command -v wget)" ]; then
echo "Downloading using wget"
wget -O "${output}" "${url}"
elif [ "$(command -v curl)" ]; then
echo "Downloading using curl"
curl "${url}" --output "${output}"
else
echo "Failed to find wget or curl."
exit 1
fi
`.trim();
}
unzip(file: string, directory: string): string {
return `tar -xf "${file}" -C "${directory}"`;
}
mkdir(path: string): string {
return `mkdir -p "${path}"`;
}
joinPath(...segments: string[]): string {
return segments.join('/');
}
joinScript(...segments: string[]): string {
return segments.join('\n');
}
}
@injectable()
export class RemoteSetupScriptService {
@inject(RemoteWindowsScriptStrategy)
protected windowsStrategy: RemoteWindowsScriptStrategy;
@inject(RemotePosixScriptStrategy)
protected posixStrategy: RemotePosixScriptStrategy;
protected getStrategy(platform: RemotePlatform): RemoteScriptStrategy {
return platform.os === OS.Type.Windows ? this.windowsStrategy : this.posixStrategy;
}
home(platform: RemotePlatform): string {
return this.getStrategy(platform).home();
}
exec(platform: RemotePlatform): string {
return this.getStrategy(platform).exec();
}
downloadFile(platform: RemotePlatform, url: string, output: string): string {
return this.getStrategy(platform).downloadFile(url, output);
}
unzip(platform: RemotePlatform, file: string, directory: string): string {
return this.getStrategy(platform).unzip(file, directory);
}
mkdir(platform: RemotePlatform, path: string): string {
return this.getStrategy(platform).mkdir(path);
}
joinPath(platform: RemotePlatform, ...segments: string[]): string {
return this.getStrategy(platform).joinPath(...segments);
}
joinScript(platform: RemotePlatform, ...segments: string[]): string {
return this.getStrategy(platform).joinScript(...segments);
}
}

View File

@@ -0,0 +1,222 @@
// *****************************************************************************
// Copyright (C) 2023 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { inject, injectable, named } from '@theia/core/shared/inversify';
import { RemoteConnection, RemoteExecResult, RemoteStatusReport } from '../remote-types';
import { RemoteCliContext, RemoteCliContribution, RemotePlatform } from '@theia/core/lib/node/remote/remote-cli-contribution';
import { ApplicationPackage } from '@theia/core/shared/@theia/application-package';
import { RemoteCopyService } from './remote-copy-service';
import { RemoteNativeDependencyService } from './remote-native-dependency-service';
import { ContributionProvider, OS, THEIA_VERSION } from '@theia/core';
import { RemoteNodeSetupService } from './remote-node-setup-service';
import { RemoteSetupScriptService } from './remote-setup-script-service';
export interface RemoteSetupOptions {
connection: RemoteConnection;
report: RemoteStatusReport;
nodeDownloadTemplate?: string;
}
export interface RemoteSetupResult {
applicationDirectory: string;
nodeDirectory: string;
}
@injectable()
export class RemoteSetupService {
@inject(RemoteCopyService)
protected readonly copyService: RemoteCopyService;
@inject(RemoteNativeDependencyService)
protected readonly nativeDependencyService: RemoteNativeDependencyService;
@inject(RemoteNodeSetupService)
protected readonly nodeSetupService: RemoteNodeSetupService;
@inject(RemoteSetupScriptService)
protected readonly scriptService: RemoteSetupScriptService;
@inject(ApplicationPackage)
protected readonly applicationPackage: ApplicationPackage;
@inject(ContributionProvider) @named(RemoteCliContribution)
protected readonly cliContributions: ContributionProvider<RemoteCliContribution>;
async setup(options: RemoteSetupOptions): Promise<RemoteSetupResult> {
const {
connection,
report,
nodeDownloadTemplate
} = options;
report('Identifying remote system...');
// 1. Identify remote platform
const platform = await this.detectRemotePlatform(connection);
// 2. Setup home directory
const remoteHome = await this.getRemoteHomeDirectory(connection, platform);
const applicationDirectory = this.scriptService.joinPath(platform, remoteHome, `.${this.getRemoteAppName()}`);
await this.mkdirRemote(connection, platform, applicationDirectory);
// 3. Download+copy node for that platform
const nodeFileName = this.nodeSetupService.getNodeFileName(platform);
const nodeDirName = this.nodeSetupService.getNodeDirectoryName(platform);
const remoteNodeDirectory = this.scriptService.joinPath(platform, applicationDirectory, nodeDirName);
const nodeDirExists = await this.dirExistsRemote(connection, remoteNodeDirectory);
if (!nodeDirExists) {
report('Downloading and installing Node.js on remote...');
// Download the binaries locally and move it via SSH
const nodeArchive = await this.nodeSetupService.downloadNode(platform, nodeDownloadTemplate);
const remoteNodeZip = this.scriptService.joinPath(platform, applicationDirectory, nodeFileName);
await connection.copy(nodeArchive, remoteNodeZip);
await this.unzipRemote(connection, platform, remoteNodeZip, applicationDirectory);
}
// 4. Copy backend to remote system
const libDir = this.scriptService.joinPath(platform, applicationDirectory, 'lib');
const libDirExists = await this.dirExistsRemote(connection, libDir);
if (!libDirExists) {
report('Installing application on remote...');
const applicationZipFile = this.scriptService.joinPath(platform, applicationDirectory, `${this.getRemoteAppName()}.tar`);
await this.copyService.copyToRemote(connection, platform, applicationZipFile);
await this.unzipRemote(connection, platform, applicationZipFile, applicationDirectory);
}
// 5. start remote backend
report('Starting application on remote...');
const port = await this.startApplication(connection, platform, applicationDirectory, remoteNodeDirectory);
connection.remotePort = port;
return {
applicationDirectory: libDir,
nodeDirectory: remoteNodeDirectory
};
}
protected async startApplication(connection: RemoteConnection, platform: RemotePlatform, remotePath: string, nodeDir: string): Promise<number> {
const nodeExecutable = this.scriptService.joinPath(platform, nodeDir, ...(platform.os === OS.Type.Windows ? ['node.exe'] : ['bin', 'node']));
const mainJsFile = this.scriptService.joinPath(platform, remotePath, 'lib', 'backend', 'main.js');
const localAddressRegex = /listening on http:\/\/0.0.0.0:(\d+)/;
let prefix = '';
if (platform.os === OS.Type.Windows) {
// We might to switch to PowerShell beforehand on Windows
prefix = this.scriptService.exec(platform) + ' ';
}
const remoteContext: RemoteCliContext = {
platform,
directory: remotePath
};
const args: string[] = ['--hostname=0.0.0.0', `--port=${connection.remotePort ?? 0}`, '--remote'];
for (const cli of this.cliContributions.getContributions()) {
if (cli.enhanceArgs) {
args.push(...await cli.enhanceArgs(remoteContext));
}
}
// Change to the remote application path and start a node process with the copied main.js file
// This way, our current working directory is set as expected
const result = await connection.execPartial(`${prefix}cd "${remotePath}";${nodeExecutable}`,
stdout => localAddressRegex.test(stdout),
[mainJsFile, ...args]);
const match = localAddressRegex.exec(result.stdout);
if (!match) {
throw new Error('Could not start remote system: ' + result.stderr);
} else {
return Number(match[1]);
}
}
protected async detectRemotePlatform(connection: RemoteConnection): Promise<RemotePlatform> {
const osResult = await connection.exec('uname -s');
let os: OS.Type | undefined;
if (osResult.stderr) {
// Only Windows systems return an error stream here
os = OS.Type.Windows;
} else if (osResult.stdout) {
if (osResult.stdout.includes('windows32') || osResult.stdout.includes('MINGW64')) {
os = OS.Type.Windows;
} else if (osResult.stdout.includes('Linux')) {
os = OS.Type.Linux;
} else if (osResult.stdout.includes('Darwin')) {
os = OS.Type.OSX;
}
}
if (!os) {
throw new Error('Failed to identify remote system: ' + osResult.stdout + '\n' + osResult.stderr);
}
let arch: string | undefined;
if (os === OS.Type.Windows) {
const processorArchitecture = await connection.exec('cmd /c echo %PROCESSOR_ARCHITECTURE%');
if (processorArchitecture.stdout.includes('64')) {
arch = 'x64';
} else if (processorArchitecture.stdout.includes('x86')) {
arch = 'x86';
}
} else {
const archResult = (await connection.exec('uname -m')).stdout;
if (archResult.includes('x86_64')) {
arch = 'x64';
} else if (archResult.match(/i\d83/)) { // i386, i483, i683
arch = 'x86';
} else if (archResult.includes('aarch64')) {
arch = 'arm64';
} else {
arch = archResult.trim();
}
}
if (!arch) {
throw new Error('Could not identify remote system architecture');
}
return {
os,
arch
};
}
protected async getRemoteHomeDirectory(connection: RemoteConnection, platform: RemotePlatform): Promise<string> {
const result = await connection.exec(this.scriptService.home(platform));
return result.stdout.trim();
}
protected getRemoteAppName(): string {
const appName = this.applicationPackage.pck.name || 'theia';
const appVersion = this.applicationPackage.pck.version || THEIA_VERSION;
return `${this.cleanupDirectoryName(`${appName}-${appVersion}`)}-remote`;
}
protected cleanupDirectoryName(name: string): string {
return name.replace(/[@<>:"\\|?*]/g, '').replace(/\//g, '-');
}
protected async mkdirRemote(connection: RemoteConnection, platform: RemotePlatform, remotePath: string): Promise<void> {
const result = await connection.exec(this.scriptService.mkdir(platform, remotePath));
if (result.stderr) {
throw new Error('Failed to create directory: ' + result.stderr);
}
}
protected async dirExistsRemote(connection: RemoteConnection, remotePath: string): Promise<boolean> {
const cdResult = await connection.exec(`cd "${remotePath}"`);
return !Boolean(cdResult.stderr);
}
protected async unzipRemote(connection: RemoteConnection, platform: RemotePlatform, remoteFile: string, remoteDirectory: string): Promise<void> {
const result = await connection.exec(this.scriptService.unzip(platform, remoteFile, remoteDirectory));
if (result.stderr) {
throw new Error('Failed to unzip: ' + result.stderr);
}
}
protected async executeScriptRemote(connection: RemoteConnection, platform: RemotePlatform, script: string): Promise<RemoteExecResult> {
return connection.exec(this.scriptService.exec(platform), [script]);
}
}

View File

@@ -0,0 +1,424 @@
// *****************************************************************************
// Copyright (C) 2023 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import * as ssh2 from 'ssh2';
import * as net from 'net';
import * as fs from '@theia/core/shared/fs-extra';
import SftpClient = require('ssh2-sftp-client');
import SshConfig from 'ssh-config';
import { Emitter, Event, MessageService, QuickInputService } from '@theia/core';
import { inject, injectable } from '@theia/core/shared/inversify';
import { RemoteSSHConnectionProvider, RemoteSSHConnectionProviderOptions, SSHConfig } from '../../electron-common/remote-ssh-connection-provider';
import { RemoteConnectionService } from '../remote-connection-service';
import { RemoteProxyServerProvider } from '../remote-proxy-server-provider';
import { RemoteConnection, RemoteExecOptions, RemoteExecResult, RemoteExecTester, RemoteStatusReport } from '../remote-types';
import { Deferred, timeout } from '@theia/core/lib/common/promise-util';
import { SSHIdentityFileCollector, SSHKey } from './ssh-identity-file-collector';
import { RemoteSetupService } from '../setup/remote-setup-service';
import { generateUuid } from '@theia/core/lib/common/uuid';
@injectable()
export class RemoteSSHConnectionProviderImpl implements RemoteSSHConnectionProvider {
@inject(RemoteConnectionService)
protected readonly remoteConnectionService: RemoteConnectionService;
@inject(RemoteProxyServerProvider)
protected readonly serverProvider: RemoteProxyServerProvider;
@inject(SSHIdentityFileCollector)
protected readonly identityFileCollector: SSHIdentityFileCollector;
@inject(RemoteSetupService)
protected readonly remoteSetup: RemoteSetupService;
@inject(QuickInputService)
protected readonly quickInputService: QuickInputService;
@inject(MessageService)
protected readonly messageService: MessageService;
protected passwordRetryCount = 3;
protected passphraseRetryCount = 3;
async matchSSHConfigHost(host: string, user?: string, customConfigFile?: string): Promise<Record<string, string | string[]> | undefined> {
const sshConfig = await this.doGetSSHConfig(customConfigFile);
const host2 = host.trim().split(':');
const record = Object.fromEntries(
Object.entries(sshConfig.compute(host2[0])).map(([k, v]) => [k.toLowerCase(), v])
);
// Generate a regexp to find wildcards and process the hostname with the wildcards
if (record.host) {
const checkHost = new RegExp('^' + (<string>record.host)
.replace(/([^\w\*\?])/g, '\\$1')
.replace(/([\?]+)/g, (...m) => '(' + '.'.repeat(m[1].length) + ')')
.replace(/\*/g, '(.+)') + '$');
const match = host2[0].match(checkHost);
if (match) {
if (record.hostname) {
record.hostname = (<string>record.hostname).replace('%h', match[1]);
}
}
if (host2[1]) {
record.port = host2[1];
}
}
return record;
}
async getSSHConfig(customConfigFile?: string): Promise<SSHConfig> {
return this.doGetSSHConfig(customConfigFile);
}
async doGetSSHConfig(customConfigFile?: string): Promise<SshConfig> {
const empty = new SshConfig();
if (!customConfigFile) {
return empty;
}
try {
const buff: Buffer = await fs.promises.readFile(customConfigFile);
const sshConfig = SshConfig.parse(buff.toString());
return sshConfig;
} catch {
return empty;
}
}
async establishConnection(options: RemoteSSHConnectionProviderOptions): Promise<string> {
const progress = await this.messageService.showProgress({
text: 'Remote SSH'
});
const report: RemoteStatusReport = message => progress.report({ message });
report('Connecting to remote system...');
try {
const remote = await this.establishSSHConnection(options.host, options.user, options.customConfigFile);
await this.remoteSetup.setup({
connection: remote,
report,
nodeDownloadTemplate: options.nodeDownloadTemplate
});
const registration = this.remoteConnectionService.register(remote);
const server = await this.serverProvider.getProxyServer(socket => {
remote.forwardOut(socket);
});
remote.onDidDisconnect(() => {
server.close();
registration.dispose();
});
const localPort = (server.address() as net.AddressInfo).port;
remote.localPort = localPort;
return localPort.toString();
} finally {
progress.cancel();
}
}
async establishSSHConnection(host: string, user: string, customConfigFile?: string): Promise<RemoteSSHConnection> {
const deferred = new Deferred<RemoteSSHConnection>();
const sshClient = new ssh2.Client();
const sshHostConfig = await this.matchSSHConfigHost(host, user, customConfigFile);
const identityFiles = await this.identityFileCollector.gatherIdentityFiles(undefined, <string[]>sshHostConfig?.identityfile);
let algorithms: ssh2.Algorithms | undefined = undefined;
if (sshHostConfig) {
if (!user && sshHostConfig.user) {
user = <string>sshHostConfig.user;
}
if (sshHostConfig.hostname) {
host = sshHostConfig.hostname + ':' + (sshHostConfig.port || '22');
} else if (sshHostConfig.port) {
host = sshHostConfig.host + ':' + (sshHostConfig.port || '22');
}
if (sshHostConfig.compression && (<string>sshHostConfig.compression).toLowerCase() === 'yes') {
algorithms = { compress: ['zlib@openssh.com', 'zlib'] };
}
}
const hostUrl = new URL(`ssh://${host}`);
const sshAuthHandler = this.getAuthHandler(user, hostUrl.hostname, identityFiles);
sshClient
.on('ready', async () => {
const connection = new RemoteSSHConnection({
client: sshClient,
id: generateUuid(),
name: hostUrl.hostname,
type: 'SSH'
});
try {
await this.testConnection(connection);
deferred.resolve(connection);
} catch (err) {
deferred.reject(err);
}
}).on('end', () => {
console.log(`Ended remote connection to host '${user}@${hostUrl.hostname}'`);
}).on('error', err => {
deferred.reject(err);
}).connect({
host: hostUrl.hostname,
port: hostUrl.port ? parseInt(hostUrl.port, 10) : undefined,
username: user,
algorithms: algorithms,
authHandler: (methodsLeft, successes, callback) => (sshAuthHandler(methodsLeft, successes, callback), undefined)
});
return deferred.promise;
}
/**
* Sometimes, ssh2.exec will not execute and retrieve any data right after the `ready` event fired.
* In this method, we just perform `echo hello` in a loop to ensure that the connection is really ready.
* See also https://github.com/mscdex/ssh2/issues/48
*/
protected async testConnection(connection: RemoteSSHConnection): Promise<void> {
for (let i = 0; i < 100; i++) {
const result = await connection.exec('echo hello');
if (result.stdout.includes('hello')) {
return;
}
await timeout(50);
}
throw new Error('SSH connection failed testing. Could not execute "echo"');
}
protected getAuthHandler(user: string, host: string, identityKeys: SSHKey[]): ssh2.AuthHandlerMiddleware {
let passwordRetryCount = this.passwordRetryCount;
let keyboardRetryCount = this.passphraseRetryCount;
// `false` is a valid return value, indicating that the authentication has failed
const END_AUTH = false as unknown as ssh2.AuthenticationType;
// `null` indicates that we just want to continue with the next auth type
// eslint-disable-next-line no-null/no-null
const NEXT_AUTH = null as unknown as ssh2.AuthenticationType;
return async (methodsLeft: string[] | null, _partialSuccess: boolean | null, callback: ssh2.NextAuthHandler) => {
if (!methodsLeft) {
return callback({
type: 'none',
username: user,
});
}
if (methodsLeft && methodsLeft.includes('publickey') && identityKeys.length) {
const identityKey = identityKeys.shift()!;
if (identityKey.isPrivate) {
return callback({
type: 'publickey',
username: user,
key: identityKey.parsedKey
});
}
if (!await fs.pathExists(identityKey.filename)) {
// Try next identity file
return callback(NEXT_AUTH);
}
const keyBuffer = await fs.promises.readFile(identityKey.filename);
let result = ssh2.utils.parseKey(keyBuffer); // First try without passphrase
if (result instanceof Error && result.message.match(/no passphrase given/)) {
let passphraseRetryCount = this.passphraseRetryCount;
while (result instanceof Error && passphraseRetryCount > 0) {
const passphrase = await this.quickInputService.input({
title: `Enter passphrase for ${identityKey.filename}`,
password: true
});
if (!passphrase) {
break;
}
result = ssh2.utils.parseKey(keyBuffer, passphrase);
passphraseRetryCount--;
}
}
if (!result || result instanceof Error) {
// Try next identity file
return callback(NEXT_AUTH);
}
const key = Array.isArray(result) ? result[0] : result;
return callback({
type: 'publickey',
username: user,
key
});
}
if (methodsLeft && methodsLeft.includes('password') && passwordRetryCount > 0) {
const password = await this.quickInputService.input({
title: `Enter password for ${user}@${host}`,
password: true
});
passwordRetryCount--;
return callback(password
? {
type: 'password',
username: user,
password
}
: END_AUTH);
}
if (methodsLeft && methodsLeft.includes('keyboard-interactive') && keyboardRetryCount > 0) {
return callback({
type: 'keyboard-interactive',
username: user,
prompt: async (_name, _instructions, _instructionsLang, prompts, finish) => {
const responses: string[] = [];
for (const prompt of prompts) {
const response = await this.quickInputService.input({
title: `(${user}@${host}) ${prompt.prompt}`,
password: !prompt.echo
});
if (response === undefined) {
keyboardRetryCount = 0;
break;
}
responses.push(response);
}
keyboardRetryCount--;
finish(responses);
}
});
}
callback(END_AUTH);
};
}
}
export interface RemoteSSHConnectionOptions {
id: string;
name: string;
type: string;
client: ssh2.Client;
}
export class RemoteSSHConnection implements RemoteConnection {
id: string;
name: string;
type: string;
client: ssh2.Client;
localPort = 0;
remotePort = 0;
private sftpClientPromise: Promise<SftpClient>;
private readonly onDidDisconnectEmitter = new Emitter<void>();
get onDidDisconnect(): Event<void> {
return this.onDidDisconnectEmitter.event;
}
constructor(options: RemoteSSHConnectionOptions) {
this.id = options.id;
this.type = options.type;
this.name = options.name;
this.client = options.client;
this.onDidDisconnect(() => this.dispose());
this.client.on('end', () => {
this.onDidDisconnectEmitter.fire();
});
this.sftpClientPromise = this.setupSftpClient();
}
protected async setupSftpClient(): Promise<SftpClient> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const sftpClient = new SftpClient() as any;
// A hack to set the internal ssh2 client of the sftp client
// That way, we don't have to create a second connection
sftpClient.client = this.client;
// Calling this function establishes the sftp connection on the ssh client
await sftpClient.getSftpChannel();
return sftpClient;
}
forwardOut(socket: net.Socket, port?: number): void {
this.client.forwardOut(socket.localAddress!, socket.localPort!, '127.0.0.1', port ?? this.remotePort, (err, stream) => {
if (err) {
console.debug('Proxy message rejected', err);
} else {
stream.pipe(socket).pipe(stream);
}
});
}
async copy(localPath: string, remotePath: string): Promise<void> {
const sftpClient = await this.sftpClientPromise;
await sftpClient.put(localPath, remotePath);
}
exec(cmd: string, args?: string[], options: RemoteExecOptions = {}): Promise<RemoteExecResult> {
const deferred = new Deferred<RemoteExecResult>();
cmd = this.buildCmd(cmd, args);
this.client.exec(cmd, options, (err, stream) => {
if (err) {
return deferred.reject(err);
}
let stdout = '';
let stderr = '';
stream.on('close', () => {
deferred.resolve({ stdout, stderr });
}).on('data', (data: Buffer | string) => {
stdout += data.toString();
}).stderr.on('data', (data: Buffer | string) => {
stderr += data.toString();
});
});
return deferred.promise;
}
execPartial(cmd: string, tester: RemoteExecTester, args?: string[], options: RemoteExecOptions = {}): Promise<RemoteExecResult> {
const deferred = new Deferred<RemoteExecResult>();
cmd = this.buildCmd(cmd, args);
this.client.exec(cmd, {
...options,
// Ensure that the process on the remote ends when the connection is closed
pty: true
}, (err, stream) => {
if (err) {
return deferred.reject(err);
}
// in pty mode we only have an stdout stream
// return stdout as stderr as well
let stdout = '';
stream.on('close', () => {
if (deferred.state === 'unresolved') {
deferred.resolve({ stdout, stderr: stdout });
}
}).on('data', (data: Buffer | string) => {
if (deferred.state === 'unresolved') {
stdout += data.toString();
if (tester(stdout, stdout)) {
deferred.resolve({ stdout, stderr: stdout });
}
}
});
});
return deferred.promise;
}
protected buildCmd(cmd: string, args?: string[]): string {
const escapedArgs = args?.map(arg => `"${arg.replace(/"/g, '\\"')}"`) || [];
const fullCmd = cmd + (escapedArgs.length > 0 ? (' ' + escapedArgs.join(' ')) : '');
return fullCmd;
}
dispose(): void {
this.client.end();
this.client.destroy();
}
}

View File

@@ -0,0 +1,137 @@
// *****************************************************************************
// Copyright (C) 2023 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import * as fs from '@theia/core/shared/fs-extra';
import * as os from 'os';
import * as path from 'path';
import * as crypto from 'crypto';
import { ParsedKey } from 'ssh2';
import * as ssh2 from 'ssh2';
import { injectable } from '@theia/core/shared/inversify';
export interface SSHKey {
filename: string;
parsedKey: ParsedKey;
fingerprint: string;
agentSupport?: boolean;
isPrivate?: boolean;
}
@injectable()
export class SSHIdentityFileCollector {
protected getDefaultIdentityFiles(): string[] {
const homeDir = os.homedir();
const PATH_SSH_CLIENT_ID_DSA = path.join(homeDir, '.ssh', '/id_dsa');
const PATH_SSH_CLIENT_ID_ECDSA = path.join(homeDir, '.ssh', '/id_ecdsa');
const PATH_SSH_CLIENT_ID_RSA = path.join(homeDir, '.ssh', '/id_rsa');
const PATH_SSH_CLIENT_ID_ED25519 = path.join(homeDir, '.ssh', '/id_ed25519');
const PATH_SSH_CLIENT_ID_XMSS = path.join(homeDir, '.ssh', '/id_xmss');
const PATH_SSH_CLIENT_ID_ECDSA_SK = path.join(homeDir, '.ssh', '/id_ecdsa_sk');
const PATH_SSH_CLIENT_ID_ED25519_SK = path.join(homeDir, '.ssh', '/id_ed25519_sk');
return [
PATH_SSH_CLIENT_ID_DSA,
PATH_SSH_CLIENT_ID_ECDSA,
PATH_SSH_CLIENT_ID_ECDSA_SK,
PATH_SSH_CLIENT_ID_ED25519,
PATH_SSH_CLIENT_ID_ED25519_SK,
PATH_SSH_CLIENT_ID_RSA,
PATH_SSH_CLIENT_ID_XMSS
];
}
async gatherIdentityFiles(sshAgentSock?: string, overrideIdentityFiles?: string[]): Promise<SSHKey[]> {
const identityFiles = overrideIdentityFiles || this.getDefaultIdentityFiles();
const identityFileContentsResult = await Promise.allSettled(identityFiles.map(async keyPath => {
keyPath = await fs.pathExists(keyPath + '.pub') ? keyPath + '.pub' : keyPath;
return fs.promises.readFile(keyPath);
}));
const fileKeys: SSHKey[] = identityFileContentsResult.map((result, i) => {
if (result.status === 'rejected') {
return undefined;
}
const parsedResult = ssh2.utils.parseKey(result.value);
if (parsedResult instanceof Error || !parsedResult) {
console.log(`Error while parsing SSH public key ${identityFiles[i]}:`, parsedResult);
return undefined;
}
const parsedKey = Array.isArray(parsedResult) ? parsedResult[0] : parsedResult;
const fingerprint = crypto.createHash('sha256').update(parsedKey.getPublicSSH()).digest('base64');
return {
filename: identityFiles[i],
parsedKey,
fingerprint
};
}).filter(<T>(v: T | undefined): v is T => !!v);
let sshAgentParsedKeys: ParsedKey[] = [];
if (sshAgentSock) {
sshAgentParsedKeys = await new Promise<ParsedKey[]>((resolve, reject) => {
const sshAgent = new ssh2.OpenSSHAgent(sshAgentSock);
sshAgent.getIdentities((err, publicKeys) => {
if (err) {
reject(err);
} else if (publicKeys) {
resolve(publicKeys.map(key => {
if ('pubKey' in key) {
const pubKey = key.pubKey;
if ('pubKey' in pubKey) {
return pubKey.pubKey as ParsedKey;
}
return pubKey;
} else {
return key;
}
}));
} else {
resolve([]);
}
});
});
}
const sshAgentKeys: SSHKey[] = sshAgentParsedKeys.map(parsedKey => {
const fingerprint = crypto.createHash('sha256').update(parsedKey.getPublicSSH()).digest('base64');
return {
filename: parsedKey.comment,
parsedKey,
fingerprint,
agentSupport: true
};
});
const agentKeys: SSHKey[] = [];
const preferredIdentityKeys: SSHKey[] = [];
for (const agentKey of sshAgentKeys) {
const foundIdx = fileKeys.findIndex(k => agentKey.parsedKey.type === k.parsedKey.type && agentKey.fingerprint === k.fingerprint);
if (foundIdx >= 0) {
preferredIdentityKeys.push({ ...fileKeys[foundIdx], agentSupport: true });
fileKeys.splice(foundIdx, 1);
} else {
agentKeys.push(agentKey);
}
}
preferredIdentityKeys.push(...agentKeys);
preferredIdentityKeys.push(...fileKeys);
return preferredIdentityKeys;
}
}

View File

@@ -0,0 +1,29 @@
// *****************************************************************************
// Copyright (C) 2023 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
/* note: this bogus test file is required so that
we are able to run mocha unit tests on this
package, without having any actual unit tests in it.
This way a coverage report will be generated,
showing 0% coverage, instead of no report.
This file can be removed once we have real unit
tests in place. */
describe('remote package', () => {
it('support code coverage statistics', () => true);
});

View File

@@ -0,0 +1,25 @@
{
"extends": "../../configs/base.tsconfig",
"compilerOptions": {
"composite": true,
"rootDir": "src",
"outDir": "lib"
},
"include": [
"src"
],
"references": [
{
"path": "../core"
},
{
"path": "../filesystem"
},
{
"path": "../userstorage"
},
{
"path": "../variable-resolver"
}
]
}