Allow registering additional external uri openers

This change moves the extension uri opener contribution point to instead use the internal `IExternalOpener` api instead of the more generic `IOpener` api. This is required since external uri openers should see the resolved uri that has gone through port forwarding, not the raw uri that the user clicked on
This commit is contained in:
Matt Bierner 2021-01-06 19:04:43 -08:00
parent bdf57b45ce
commit e2c305f3a3
9 changed files with 76 additions and 34 deletions

View file

@ -36,7 +36,7 @@ export function activate(context: vscode.ExtensionContext) {
}));
context.subscriptions.push(vscode.window.registerExternalUriOpener(['http', 'https'], {
openExternalUri(uri: vscode.Uri): vscode.Command | undefined {
openExternalUri(uri: vscode.Uri, context: vscode.OpenExternalUriContext): vscode.Command | undefined {
const configuration = vscode.workspace.getConfiguration('simpleBrowser');
if (!configuration.get('opener.enabled', false)) {
return undefined;
@ -47,8 +47,11 @@ export function activate(context: vscode.ExtensionContext) {
'127.0.0.1'
]);
try {
const url = new URL(uri.toString());
if (!enabledHosts.includes(url.hostname)) {
// Check against the original uri that triggered the open.
// We check this since the `uri` passed to us may have been transformed
// by port forwarding.
const originalUri = new URL(context.originalUri.toString());
if (!enabledHosts.includes(originalUri.hostname)) {
return;
}
} catch {

View file

@ -6,15 +6,15 @@
import * as dom from 'vs/base/browser/dom';
import { IDisposable } from 'vs/base/common/lifecycle';
import { LinkedList } from 'vs/base/common/linkedList';
import { ResourceMap } from 'vs/base/common/map';
import { parse } from 'vs/base/common/marshalling';
import { Schemas } from 'vs/base/common/network';
import { normalizePath } from 'vs/base/common/resources';
import { URI } from 'vs/base/common/uri';
import { ICodeEditorService } from 'vs/editor/browser/services/codeEditorService';
import { ICommandService } from 'vs/platform/commands/common/commands';
import { IOpener, IOpenerService, IValidator, IExternalUriResolver, OpenOptions, ResolveExternalUriOptions, IResolvedExternalUri, IExternalOpener, matchesScheme } from 'vs/platform/opener/common/opener';
import { EditorOpenContext } from 'vs/platform/editor/common/editor';
import { ResourceMap } from 'vs/base/common/map';
import { IExternalOpener, IExternalUriResolver, IOpener, IOpenerService, IResolvedExternalUri, IValidator, matchesScheme, OpenOptions, ResolveExternalUriOptions } from 'vs/platform/opener/common/opener';
class CommandOpener implements IOpener {
@ -99,14 +99,15 @@ export class OpenerService implements IOpenerService {
private readonly _resolvers = new LinkedList<IExternalUriResolver>();
private readonly _resolvedUriTargets = new ResourceMap<URI>(uri => uri.with({ path: null, fragment: null, query: null }).toString());
private _externalOpener: IExternalOpener;
private _defaultExternalOpener: IExternalOpener;
private readonly _additionalExternalOpeners = new LinkedList<IExternalOpener>();
constructor(
@ICodeEditorService editorService: ICodeEditorService,
@ICommandService commandService: ICommandService,
) {
// Default external opener is going through window.open()
this._externalOpener = {
this._defaultExternalOpener = {
openExternal: async href => {
// ensure to open HTTP/HTTPS links into new windows
// to not trigger a navigation. Any other link is
@ -151,8 +152,13 @@ export class OpenerService implements IOpenerService {
return { dispose: remove };
}
setExternalOpener(externalOpener: IExternalOpener): void {
this._externalOpener = externalOpener;
setDefaultExternalOpener(externalOpener: IExternalOpener): void {
this._defaultExternalOpener = externalOpener;
}
registerAdditionalExternalOpener(externalOpener: IExternalOpener): IDisposable {
const remove = this._additionalExternalOpeners.push(externalOpener);
return { dispose: remove };
}
async open(target: URI | string, options?: OpenOptions): Promise<boolean> {
@ -195,13 +201,21 @@ export class OpenerService implements IOpenerService {
const uri = typeof resource === 'string' ? URI.parse(resource) : resource;
const { resolved } = await this.resolveExternalUri(uri, options);
let href: string;
if (typeof resource === 'string' && uri.toString() === resolved.toString()) {
// open the url-string AS IS
return this._externalOpener.openExternal(resource);
href = resource;
} else {
// open URI using the toString(noEncode)+encodeURI-trick
return this._externalOpener.openExternal(encodeURI(resolved.toString(true)));
href = encodeURI(resolved.toString(true));
}
for (const opener of this._additionalExternalOpeners) {
if (await opener.openExternal(href, uri)) {
return true;
}
}
return this._defaultExternalOpener.openExternal(encodeURI(resolved.toString(true)), uri);
}
dispose() {

View file

@ -46,7 +46,7 @@ export interface IOpener {
}
export interface IExternalOpener {
openExternal(href: string): Promise<boolean>;
openExternal(href: string, originalUri: URI): Promise<boolean>;
}
export interface IValidator {
@ -81,7 +81,13 @@ export interface IOpenerService {
* Sets the handler for opening externally. If not provided,
* a default handler will be used.
*/
setExternalOpener(opener: IExternalOpener): void;
setDefaultExternalOpener(opener: IExternalOpener): void;
/**
* Registers an additional opener for external resources that is checked
* before the default external opener.
*/
registerAdditionalExternalOpener(externalOpener: IExternalOpener): IDisposable;
/**
* Opens a resource, like a webaddress, a document uri, or executes command.
@ -97,15 +103,16 @@ export interface IOpenerService {
resolveExternalUri(resource: URI, options?: ResolveExternalUriOptions): Promise<IResolvedExternalUri>;
}
export const NullOpenerService: IOpenerService = Object.freeze({
export const NullOpenerService = Object.freeze({
_serviceBrand: undefined,
registerOpener() { return Disposable.None; },
registerValidator() { return Disposable.None; },
registerExternalUriResolver() { return Disposable.None; },
setExternalOpener() { },
setDefaultExternalOpener() { },
registerAdditionalExternalOpener() { return Disposable.None; },
async open() { return false; },
async resolveExternalUri(uri: URI) { return { resolved: uri, dispose() { } }; },
});
} as IOpenerService);
export function matchesScheme(target: URI | string, scheme: string) {
if (URI.isUri(target)) {

View file

@ -2301,6 +2301,19 @@ declare module 'vscode' {
*/
location?: Location;
}
/**
* Additional metadata about the uri being opened
*/
interface OpenExternalUriContext {
/**
* The original uri the open was triggered for.
*
* This may differ from the uri passed to `openExternalUri` due to port forwarding.
*/
readonly originalUri: Uri;
}
//#endregion
//#region Opener service (https://github.com/microsoft/vscode/issues/109277)
@ -2318,8 +2331,9 @@ declare module 'vscode' {
/**
* Try to open a given uri.
*
* @param uri The uri being opened.
* @param ctx Additional metadata about how the open was triggered.
* @param uri The uri to open. This uri may have been transformed by port forwarding. To access
* the original uri that triggered the open, use `ctx.original`.
* @param ctx Additional metadata about the triggered open.
* @param token Cancellation token.
*
* @return Optional command that opens the uri. If no command is returned, VS Code will
@ -2327,7 +2341,7 @@ declare module 'vscode' {
*
* If multiple openers are available for a given uri, then the `Command.title` is shown in the UI.
*/
openExternalUri(uri: Uri, ctx: {}, token: CancellationToken): ProviderResult<Command>;
openExternalUri(uri: Uri, ctx: OpenExternalUriContext, token: CancellationToken): ProviderResult<Command>;
}
namespace window {

View file

@ -4,16 +4,16 @@
*--------------------------------------------------------------------------------------------*/
import { CancellationToken } from 'vs/base/common/cancellation';
import { Disposable } from 'vs/base/common/lifecycle';
import { Schemas } from 'vs/base/common/network';
import { URI } from 'vs/base/common/uri';
import { IOpener, IOpenerService, OpenExternalOptions, OpenInternalOptions } from 'vs/platform/opener/common/opener';
import { IExternalOpener, IOpenerService } from 'vs/platform/opener/common/opener';
import { ExtHostContext, ExtHostUriOpenersShape, IExtHostContext, MainContext, MainThreadUriOpenersShape } from 'vs/workbench/api/common/extHost.protocol';
import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions';
import { extHostNamedCustomer } from '../common/extHostCustomers';
@extHostNamedCustomer(MainContext.MainThreadUriOpeners)
export class MainThreadUriOpeners implements MainThreadUriOpenersShape, IOpener {
export class MainThreadUriOpeners extends Disposable implements MainThreadUriOpenersShape, IExternalOpener {
private readonly proxy: ExtHostUriOpenersShape;
private readonly handlers = new Map<number, { schemes: ReadonlySet<string> }>();
@ -23,16 +23,14 @@ export class MainThreadUriOpeners implements MainThreadUriOpenersShape, IOpener
@IOpenerService private readonly openerService: IOpenerService,
@IExtensionService private readonly extensionService: IExtensionService,
) {
super();
this.proxy = context.getProxy(ExtHostContext.ExtHostUriOpeners);
this.openerService.registerOpener(this);
this._register(this.openerService.registerAdditionalExternalOpener(this));
}
async open(
target: string | URI,
options?: OpenInternalOptions | OpenExternalOptions
): Promise<boolean> {
const targetUri = typeof target === 'string' ? URI.parse(target) : target;
public async openExternal(href: string, originalUri: URI): Promise<boolean> {
const targetUri = URI.parse(href);
// Currently we only allow openers for http and https urls
if (targetUri.scheme !== Schemas.http && targetUri.scheme !== Schemas.https) {
@ -47,7 +45,9 @@ export class MainThreadUriOpeners implements MainThreadUriOpenersShape, IOpener
return false;
}
return await this.proxy.$openUri(targetUri, CancellationToken.None);
return await this.proxy.$openUri(targetUri, {
originalUri: originalUri,
}, CancellationToken.None);
}
async $registerUriOpener(handle: number, schemes: readonly string[]): Promise<void> {
@ -59,6 +59,7 @@ export class MainThreadUriOpeners implements MainThreadUriOpenersShape, IOpener
}
dispose(): void {
super.dispose();
this.handlers.clear();
}
}

View file

@ -806,7 +806,7 @@ export interface MainThreadUriOpenersShape extends IDisposable {
}
export interface ExtHostUriOpenersShape {
$openUri(uri: UriComponents, token: CancellationToken): Promise<boolean>;
$openUri(uri: UriComponents, ctx: { originalUri: UriComponents }, token: CancellationToken): Promise<boolean>;
}
export interface ITextSearchComplete {

View file

@ -49,7 +49,7 @@ export class ExtHostUriOpeners implements ExtHostUriOpenersShape {
});
}
async $openUri(uriComponents: UriComponents, token: CancellationToken): Promise<boolean> {
async $openUri(uriComponents: UriComponents, ctx: { originalUri: UriComponents }, token: CancellationToken): Promise<boolean> {
const uri = URI.revive(uriComponents);
const promises = Array.from(this._openers.values()).map(async ({ schemes, opener }): Promise<vscode.Command | undefined> => {
@ -58,7 +58,10 @@ export class ExtHostUriOpeners implements ExtHostUriOpenersShape {
}
try {
const result = await opener.openExternalUri(uri, {}, token);
const result = await opener.openExternalUri(uri, {
originalUri: URI.revive(ctx.originalUri),
}, token);
if (result) {
return result;
}

View file

@ -85,7 +85,7 @@ export class BrowserWindow extends Disposable {
// in a new window because that would leave a blank
// window to the user, but using `window.location.href`
// will trigger the `beforeunload`.
this.openerService.setExternalOpener({
this.openerService.setDefaultExternalOpener({
openExternal: async (href: string) => {
if (matchesScheme(href, Schemas.http) || matchesScheme(href, Schemas.https)) {
windowOpenNoOpener(href);

View file

@ -484,7 +484,7 @@ export class NativeWindow extends Disposable {
};
// Handle external open() calls
this.openerService.setExternalOpener({
this.openerService.setDefaultExternalOpener({
openExternal: async (href: string) => {
const success = await this.nativeHostService.openExternal(href);
if (!success) {