deploy: current vibn theia state
Made-with: Cursor
This commit is contained in:
10
packages/remote/.eslintrc.js
Normal file
10
packages/remote/.eslintrc.js
Normal 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
61
packages/remote/README.md
Normal 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>
|
||||
70
packages/remote/package.json
Normal file
70
packages/remote/package.json
Normal 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"
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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!);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
31
packages/remote/src/electron-browser/remote-service.ts
Normal file
31
packages/remote/src/electron-browser/remote-service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
185
packages/remote/src/electron-browser/remote-ssh-contribution.ts
Normal file
185
packages/remote/src/electron-browser/remote-ssh-contribution.ts
Normal 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()
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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[]>
|
||||
}
|
||||
68
packages/remote/src/electron-common/remote-preferences.ts
Normal file
68
packages/remote/src/electron-common/remote-preferences.ts
Normal 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);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
37
packages/remote/src/electron-common/remote-status-service.ts
Normal file
37
packages/remote/src/electron-common/remote-status-service.ts
Normal 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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
89
packages/remote/src/electron-node/remote-backend-module.ts
Normal file
89
packages/remote/src/electron-node/remote-backend-module.ts
Normal 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);
|
||||
});
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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 }));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
48
packages/remote/src/electron-node/remote-status-service.ts
Normal file
48
packages/remote/src/electron-node/remote-status-service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
69
packages/remote/src/electron-node/remote-types.ts
Normal file
69
packages/remote/src/electron-node/remote-types.ts
Normal 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;
|
||||
}
|
||||
@@ -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'
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
116
packages/remote/src/electron-node/setup/remote-copy-service.ts
Normal file
116
packages/remote/src/electron-node/setup/remote-copy-service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
222
packages/remote/src/electron-node/setup/remote-setup-service.ts
Normal file
222
packages/remote/src/electron-node/setup/remote-setup-service.ts
Normal 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]);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
29
packages/remote/src/package.spec.ts
Normal file
29
packages/remote/src/package.spec.ts
Normal 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);
|
||||
|
||||
});
|
||||
25
packages/remote/tsconfig.json
Normal file
25
packages/remote/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user