Skip to content

Commit

Permalink
[plugin]: support webview port mapping and external URIs
Browse files Browse the repository at this point in the history
Signed-off-by: Anton Kosyakov <anton.kosyakov@typefox.io>
  • Loading branch information
akosyakov committed Nov 7, 2019
1 parent 2176bd6 commit f43bd86
Show file tree
Hide file tree
Showing 9 changed files with 157 additions and 7 deletions.
54 changes: 54 additions & 0 deletions packages/core/src/browser/external-uri-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/********************************************************************************
* Copyright (C) 2019 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 WITH Classpath-exception-2.0
********************************************************************************/

import { injectable } from 'inversify';
import URI from '../common/uri';
import { MaybePromise } from '../common/types';

@injectable()
export class ExternalUriService {

/**
* Maps local to remote URLs.
* Should be no-op if the given URL is not a localhost URL.
*
* By default maps to a host serving Theia.
*
* Use `parseLocalhost` to retrive localhost address and port information.
*/
resolve(uri: URI): MaybePromise<URI> {
const localhost = this.parseLocalhost(uri);
if (localhost) {
return uri.withAuthority(`${window.location.hostname}:${localhost.port}`);
}
return uri;
}

parseLocalhost(uri: URI): { address: string, port: number } | undefined {
if (uri.scheme !== 'http' && uri.scheme !== 'https') {
return undefined;
}
const localhostMatch = /^(localhost|127\.0\.0\.1|0\.0\.0\.0):(\d+)$/.exec(uri.authority);
if (!localhostMatch) {
return undefined;
}
return {
address: localhostMatch[1],
port: +localhostMatch[2],
};
}

}
3 changes: 3 additions & 0 deletions packages/core/src/browser/frontend-application-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ import { ContextMenuContext } from './menu/context-menu-context';
import { bindResourceProvider, bindMessageService, bindPreferenceService } from './frontend-application-bindings';
import { ColorRegistry } from './color-registry';
import { ColorContribution, ColorApplicationContribution } from './color-application-contribution';
import { ExternalUriService } from './external-uri-service';

export { bindResourceProvider, bindMessageService, bindPreferenceService };

Expand Down Expand Up @@ -138,6 +139,8 @@ export const frontendApplicationModule = new ContainerModule((bind, unbind, isBo
bindContributionProvider(bind, OpenHandler);
bind(DefaultOpenerService).toSelf().inSingletonScope();
bind(OpenerService).toService(DefaultOpenerService);

bind(ExternalUriService).toSelf().inSingletonScope();
bind(HttpOpenHandler).toSelf().inSingletonScope();
bind(OpenHandler).toService(HttpOpenHandler);

Expand Down
9 changes: 7 additions & 2 deletions packages/core/src/browser/http-open-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { injectable, inject } from 'inversify';
import URI from '../common/uri';
import { OpenHandler } from './opener-service';
import { WindowService } from './window/window-service';
import { ExternalUriService } from './external-uri-service';

@injectable()
export class HttpOpenHandler implements OpenHandler {
Expand All @@ -27,12 +28,16 @@ export class HttpOpenHandler implements OpenHandler {
@inject(WindowService)
protected readonly windowService: WindowService;

@inject(ExternalUriService)
protected readonly externalUriService: ExternalUriService;

canHandle(uri: URI): number {
return uri.scheme.startsWith('http') ? 500 : 0;
}

open(uri: URI): Window | undefined {
return this.windowService.openNewWindow(uri.toString(true), { external: true });
async open(uri: URI): Promise<Window | undefined> {
const resolvedUri = await this.externalUriService.resolve(uri);
return this.windowService.openNewWindow(resolvedUri.toString(true), { external: true });
}

}
1 change: 1 addition & 0 deletions packages/plugin-ext/src/common/plugin-api-rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,7 @@ export enum TreeViewItemCollapsibleState {

export interface WindowMain {
$openUri(uri: UriComponents): Promise<boolean>;
$asExternalUri(uri: UriComponents): Promise<UriComponents>;
}

export interface WindowStateExt {
Expand Down
38 changes: 38 additions & 0 deletions packages/plugin-ext/src/main/browser/webview/webview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import { Schemes } from '../../../common/uri-components';
import { PluginSharedStyle } from '../plugin-shared-style';
import { BuiltinThemeProvider } from '@theia/core/lib/browser/theming';
import { WebviewThemeDataProvider } from './webview-theme-data-provider';
import { ExternalUriService } from '@theia/core/lib/browser/external-uri-service';

// tslint:disable:no-any

Expand All @@ -54,6 +55,7 @@ export const enum WebviewMessageChannels {
doUpdateState = 'do-update-state',
doReload = 'do-reload',
loadResource = 'load-resource',
loadLocalhost = 'load-localhost',
webviewReady = 'webview-ready',
didKeydown = 'did-keydown'
}
Expand Down Expand Up @@ -113,6 +115,9 @@ export class WebviewWidget extends BaseWidget implements StatefulWidget {
@inject(WebviewThemeDataProvider)
protected readonly themeDataProvider: WebviewThemeDataProvider;

@inject(ExternalUriService)
protected readonly externalUriService: ExternalUriService;

viewState: WebviewPanelViewState = {
visible: false,
active: false,
Expand Down Expand Up @@ -240,6 +245,9 @@ export class WebviewWidget extends BaseWidget implements StatefulWidget {
const uri = new URI(normalizedPath.replace(/^\/(\w+)\/(.+)$/, (_, scheme, path) => scheme + ':/' + path));
this.loadResource(rawPath, uri);
}));
this.toHide.push(this.on(WebviewMessageChannels.loadLocalhost, (entry: any) =>
this.loadLocalhost(entry.origin)
));
this.toHide.push(this.on(WebviewMessageChannels.didKeydown, (data: KeyboardEvent) => {
// Electron: workaround for https://github.com/electron/electron/issues/14258
// We have to detect keyboard events in the <webview> and dispatch them to our
Expand All @@ -251,6 +259,36 @@ export class WebviewWidget extends BaseWidget implements StatefulWidget {
this.toHide.push(this.themeDataProvider.onDidChangeThemeData(() => this.style()));
}

protected async loadLocalhost(origin: string): Promise<void> {
const redirect = await this.getRedirect(origin);
return this.doSend('did-load-localhost', { origin, location: redirect });
}

protected async getRedirect(url: string): Promise<string | undefined> {
if (!this._contentOptions.portMapping || !this._contentOptions.portMapping.length) {
return undefined;
}

const uri = new URI(url);
const localhost = this.externalUriService.parseLocalhost(uri);
if (!localhost) {
return undefined;
}

for (const mapping of this._contentOptions.portMapping) {
if (mapping.webviewPort === localhost.port) {
if (mapping.webviewPort !== mapping.extensionHostPort) {
const resolved = await this.externalUriService.resolve(
uri.withAuthority(`${localhost.address}:${mapping.extensionHostPort}`)
);
return resolved.toString();
}
}
}

return undefined;
}

protected get externalEndpoint(): string {
const endpoint = this.environment.externalEndpoint.replace('{{uuid}}', this.identifier.id);
if (endpoint[endpoint.length - 1] === '/') {
Expand Down
21 changes: 16 additions & 5 deletions packages/plugin-ext/src/main/browser/window-state-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,24 +15,29 @@
********************************************************************************/

import URI from 'vscode-uri';
import CoreURI from '@theia/core/lib/common/uri';
import { interfaces } from 'inversify';
import { WindowStateExt, MAIN_RPC_CONTEXT, WindowMain } from '../../common/plugin-api-rpc';
import { RPCProtocol } from '../../common/rpc-protocol';
import { UriComponents } from '../../common/uri-components';
import { Disposable, DisposableCollection } from '@theia/core/lib/common/disposable';
import { WindowService } from '@theia/core/lib/browser/window/window-service';
import { open, OpenerService } from '@theia/core/lib/browser/opener-service';
import { ExternalUriService } from '@theia/core/lib/browser/external-uri-service';

export class WindowStateMain implements WindowMain, Disposable {

private readonly proxy: WindowStateExt;

private readonly windowService: WindowService;
private readonly openerService: OpenerService;

private readonly externalUriService: ExternalUriService;

private readonly toDispose = new DisposableCollection();

constructor(rpc: RPCProtocol, container: interfaces.Container) {
this.proxy = rpc.getProxy(MAIN_RPC_CONTEXT.WINDOW_STATE_EXT);
this.windowService = container.get(WindowService);
this.openerService = container.get(OpenerService);
this.externalUriService = container.get(ExternalUriService);

const fireDidFocus = () => this.onFocusChanged(true);
window.addEventListener('focus', fireDidFocus);
Expand All @@ -53,13 +58,19 @@ export class WindowStateMain implements WindowMain, Disposable {

async $openUri(uriComponent: UriComponents): Promise<boolean> {
const uri = URI.revive(uriComponent);
const url = encodeURI(uri.toString(true));
const url = new CoreURI(encodeURI(uri.toString(true)));
try {
this.windowService.openNewWindow(url, { external: true });
await open(this.openerService, url);
return true;
} catch (e) {
return false;
}
}

async $asExternalUri(uriComponents: UriComponents): Promise<URI> {
const uri = URI.revive(uriComponents);
const resolved = await this.externalUriService.resolve(new CoreURI(uri));
return resolved['codeUri'];
}

}
3 changes: 3 additions & 0 deletions packages/plugin-ext/src/plugin/plugin-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,9 @@ export function createAPIFactory(
},
openExternal(uri: theia.Uri): PromiseLike<boolean> {
return windowStateExt.openUri(uri);
},
asExternalUri(target: theia.Uri): PromiseLike<theia.Uri> {
return windowStateExt.asExternalUri(target);
}
});

Expand Down
13 changes: 13 additions & 0 deletions packages/plugin-ext/src/plugin/window-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { WindowState } from '@theia/plugin';
import { WindowStateExt, WindowMain, PLUGIN_RPC_CONTEXT } from '../common/plugin-api-rpc';
import { Event, Emitter } from '@theia/core/lib/common/event';
import { RPCProtocol } from '../common/rpc-protocol';
import { Schemes } from '../common/uri-components';

export class WindowStateExtImpl implements WindowStateExt {

Expand Down Expand Up @@ -52,4 +53,16 @@ export class WindowStateExtImpl implements WindowStateExt {
return this.proxy.$openUri(uri);
}

async asExternalUri(target: URI): Promise<URI> {
if (!target.scheme.trim().length) {
throw new Error('Invalid scheme - cannot be empty');
}
if (Schemes.HTTP !== target.scheme && Schemes.HTTPS !== target.scheme) {
throw new Error(`Invalid scheme '${target.scheme}'`);
}

const uri = await this.proxy.$asExternalUri(target);
return URI.revive(uri);
}

}
22 changes: 22 additions & 0 deletions packages/plugin/src/theia.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4868,6 +4868,28 @@ declare module '@theia/plugin' {
*/
export function openExternal(target: Uri): PromiseLike<boolean>;

/**
* Resolves an *external* uri, such as a `http:` or `https:` link, from where the extension is running to a
* uri to the same resource on the client machine.
*
* This is a no-op if the extension is running on the client machine. Currently only supports
* `https:` and `http:` uris.
*
* If the extension is running remotely, this function automatically establishes a port forwarding tunnel
* from the local machine to `target` on the remote and returns a local uri to the tunnel. The lifetime of
* the port fowarding tunnel is managed by VS Code and the tunnel can be closed by the user.
*
* Extensions should not cache the result of `asExternalUri` as the resolved uri may become invalid due to
* a system or user action — for example, in remote cases, a user may close a port forwardng tunnel
* that was opened by `asExternalUri`.
*
* *Note* that uris passed through `openExternal` are automatically resolved and you should not call `asExternalUri`
* on them.
*
* @return A uri that can be used on the client machine.
*/
export function asExternalUri(target: Uri): PromiseLike<Uri>;

}

/**
Expand Down

0 comments on commit f43bd86

Please sign in to comment.