Reworking external opener implementation to allow configured openers to be called directly without a canOpen check

If the user has configured a specific external uri opener, we should always try to use that without first calling `canOpen` to filter down the list of openers.

This change also adds `ExternalUriOpenerEnablement` which allows an opener to mark itself as the preferred opener for a given uri. If only a single preferred opener is returned, it will be used automatically for that uri (although user configuration can override this)
This commit is contained in:
Matt Bierner 2021-01-13 21:40:49 -08:00
parent 5b1e59c636
commit 5d6cba5cbc
15 changed files with 397 additions and 172 deletions

View file

@ -49,15 +49,17 @@ export function activate(context: vscode.ExtensionContext) {
canOpenExternalUri(uri: vscode.Uri) {
const configuration = vscode.workspace.getConfiguration('simpleBrowser');
if (!configuration.get('opener.enabled', false)) {
return false;
return vscode.ExternalUriOpenerEnablement.Disabled;
}
const originalUri = new URL(uri.toString());
if (enabledHosts.has(originalUri.hostname)) {
return true;
return isWeb()
? vscode.ExternalUriOpenerEnablement.Preferred
: vscode.ExternalUriOpenerEnablement.Enabled;
}
return false;
return vscode.ExternalUriOpenerEnablement.Disabled;
},
openExternalUri(resolveUri: vscode.Uri) {
return manager.show(resolveUri.toString());
@ -67,3 +69,8 @@ export function activate(context: vscode.ExtensionContext) {
label: localize('openTitle', "Open in simple browser"),
}));
}
function isWeb(): boolean {
// @ts-expect-error
return typeof navigator !== 'undefined' && vscode.env.uiKind === vscode.UIKind.Web;
}

View file

@ -4,6 +4,7 @@
*--------------------------------------------------------------------------------------------*/
import * as dom from 'vs/base/browser/dom';
import { CancellationToken } from 'vs/base/common/cancellation';
import { IDisposable } from 'vs/base/common/lifecycle';
import { LinkedList } from 'vs/base/common/linkedList';
import { ResourceMap } from 'vs/base/common/map';
@ -211,13 +212,13 @@ export class OpenerService implements IOpenerService {
}
for (const opener of this._externalOpeners) {
const didOpen = await opener.openExternal(href);
const didOpen = await opener.openExternal(href, { sourceUri: uri }, CancellationToken.None);
if (didOpen) {
return true;
}
}
return this._defaultExternalOpener.openExternal(href);
return this._defaultExternalOpener.openExternal(href, { sourceUri: uri }, CancellationToken.None);
}
dispose() {

View file

@ -1876,3 +1876,13 @@ export interface ITokenizationRegistry {
* @internal
*/
export const TokenizationRegistry = new TokenizationRegistryImpl();
/**
* @internal
*/
export enum ExternalUriOpenerEnablement {
Disabled,
Enabled,
Preferred
}

View file

@ -3,11 +3,12 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { URI } from 'vs/base/common/uri';
import { createDecorator } from 'vs/platform/instantiation/common/instantiation';
import { IDisposable, Disposable } from 'vs/base/common/lifecycle';
import { CancellationToken } from 'vs/base/common/cancellation';
import { Disposable, IDisposable } from 'vs/base/common/lifecycle';
import { equalsIgnoreCase, startsWithIgnoreCase } from 'vs/base/common/strings';
import { URI } from 'vs/base/common/uri';
import { IEditorOptions } from 'vs/platform/editor/common/editor';
import { createDecorator } from 'vs/platform/instantiation/common/instantiation';
export const IOpenerService = createDecorator<IOpenerService>('openerService');
@ -46,7 +47,7 @@ export interface IOpener {
}
export interface IExternalOpener {
openExternal(href: string): Promise<boolean>;
openExternal(href: string, ctx: { sourceUri: URI }, token: CancellationToken): Promise<boolean>;
dispose?(): void;
}

View file

@ -2288,6 +2288,23 @@ declare module 'vscode' {
//#region Opener service (https://github.com/microsoft/vscode/issues/109277)
export enum ExternalUriOpenerEnablement {
/**
* The opener cannot handle the uri.
*/
Disabled = 0,
/**
* The opener can handle the uri.
*/
Enabled = 1,
/**
* The opener can handle the uri and should be automatically selected if possible.
*/
Preferred = 2
}
/**
* Handles opening uris to external resources, such as http(s) links.
*
@ -2305,9 +2322,9 @@ declare module 'vscode' {
* not yet gone through port forwarding.
* @param token Cancellation token indicating that the result is no longer needed.
*
* @return True if the opener can open the external uri.
* @return If the opener can open the external uri.
*/
canOpenExternalUri(uri: Uri, token: CancellationToken): ProviderResult<boolean>;
canOpenExternalUri(uri: Uri, token: CancellationToken): ProviderResult<ExternalUriOpenerEnablement>;
/**
* Open the given uri.

View file

@ -3,7 +3,6 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { CancellationToken } from 'vs/base/common/cancellation';
import { isPromiseCanceledError } from 'vs/base/common/errors';
import { Disposable } from 'vs/base/common/lifecycle';
import { Schemas } from 'vs/base/common/network';
@ -13,7 +12,7 @@ import { ExtensionIdentifier } from 'vs/platform/extensions/common/extensions';
import { INotificationService } from 'vs/platform/notification/common/notification';
import { ExtHostContext, ExtHostUriOpenersShape, IExtHostContext, MainContext, MainThreadUriOpenersShape } from 'vs/workbench/api/common/extHost.protocol';
import { externalUriOpenerIdSchemaAddition } from 'vs/workbench/contrib/externalUriOpener/common/configuration';
import { ExternalOpenerEntry, IExternalOpenerProvider, IExternalUriOpenerService } from 'vs/workbench/contrib/externalUriOpener/common/externalUriOpenerService';
import { IExternalOpenerProvider, IExternalUriOpener, IExternalUriOpenerService } from 'vs/workbench/contrib/externalUriOpener/common/externalUriOpenerService';
import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions';
import { extHostNamedCustomer } from '../common/extHostCustomers';
@ -31,45 +30,42 @@ export class MainThreadUriOpeners extends Disposable implements MainThreadUriOpe
constructor(
context: IExtHostContext,
@IExternalUriOpenerService private readonly externalUriOpenerService: IExternalUriOpenerService,
@IExternalUriOpenerService externalUriOpenerService: IExternalUriOpenerService,
@IExtensionService private readonly extensionService: IExtensionService,
@INotificationService private readonly notificationService: INotificationService,
) {
super();
this.proxy = context.getProxy(ExtHostContext.ExtHostUriOpeners);
this._register(this.externalUriOpenerService.registerExternalOpenerProvider(this));
this._register(externalUriOpenerService.registerExternalOpenerProvider(this));
}
public async provideExternalOpeners(href: string | URI): Promise<readonly ExternalOpenerEntry[]> {
const targetUri = typeof href === 'string' ? URI.parse(href) : href;
public async *getOpeners(targetUri: URI): AsyncIterable<IExternalUriOpener> {
// Currently we only allow openers for http and https urls
if (targetUri.scheme !== Schemas.http && targetUri.scheme !== Schemas.https) {
return [];
return;
}
await this.extensionService.activateByEvent(`onUriOpen:${targetUri.scheme}`);
// If there are no handlers there is no point in making a round trip
const hasHandler = Array.from(this._registeredOpeners.values()).some(x => x.schemes.has(targetUri.scheme));
if (!hasHandler) {
return [];
for (const [id, openerMetadata] of this._registeredOpeners) {
if (openerMetadata.schemes.has(targetUri.scheme)) {
yield this.createOpener(id, openerMetadata);
}
}
const openerIds = await this.proxy.$getOpenersForUri(targetUri, CancellationToken.None);
return openerIds.map(id => this.createOpener(id, targetUri));
}
private createOpener(openerId: string, sourceUri: URI): ExternalOpenerEntry {
const metadata = this._registeredOpeners.get(openerId)!;
private createOpener(id: string, metadata: RegisteredOpenerMetadata): IExternalUriOpener {
return {
id: openerId,
id: id,
label: metadata.label,
openExternal: async (href) => {
const resolveUri = URI.parse(href);
canOpen: (uri, token) => {
return this.proxy.$canOpenUri(id, uri, token);
},
openExternalUri: async (uri, ctx, token) => {
try {
await this.proxy.$openUri(openerId, { resolveUri, sourceUri }, CancellationToken.None);
await this.proxy.$openUri(id, { resolvedUri: uri, sourceUri: ctx.sourceUri }, token);
} catch (e) {
if (!isPromiseCanceledError(e)) {
this.notificationService.error(localize('openerFailedMessage', "Could not open uri: {0}", e.toString()));

View file

@ -1133,6 +1133,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I
EventEmitter: Emitter,
ExtensionKind: extHostTypes.ExtensionKind,
ExtensionMode: extHostTypes.ExtensionMode,
ExternalUriOpenerEnablement: extHostTypes.ExternalUriOpenerEnablement,
FileChangeType: extHostTypes.FileChangeType,
FileDecoration: extHostTypes.FileDecoration,
FileSystemError: extHostTypes.FileSystemError,

View file

@ -807,8 +807,8 @@ export interface MainThreadUriOpenersShape extends IDisposable {
}
export interface ExtHostUriOpenersShape {
$getOpenersForUri(uri: UriComponents, token: CancellationToken): Promise<readonly string[]>;
$openUri(id: string, context: { resolveUri: UriComponents, sourceUri: UriComponents }, token: CancellationToken): Promise<void>;
$canOpenUri(id: string, uri: UriComponents, token: CancellationToken): Promise<modes.ExternalUriOpenerEnablement>;
$openUri(id: string, context: { resolvedUri: UriComponents, sourceUri: UriComponents }, token: CancellationToken): Promise<void>;
}
export interface ITextSearchComplete {

View file

@ -2976,3 +2976,9 @@ export type RequiredTestItem = {
//#endregion
export enum ExternalUriOpenerEnablement {
Disabled = 0,
Enabled = 1,
Preferred = 2
}

View file

@ -6,6 +6,7 @@
import { CancellationToken } from 'vs/base/common/cancellation';
import { toDisposable } from 'vs/base/common/lifecycle';
import { URI, UriComponents } from 'vs/base/common/uri';
import * as modes from 'vs/editor/common/modes';
import { ExtensionIdentifier } from 'vs/platform/extensions/common/extensions';
import type * as vscode from 'vscode';
import { ExtHostUriOpenersShape, IMainContext, MainContext, MainThreadUriOpenersShape } from './extHost.protocol';
@ -54,35 +55,23 @@ export class ExtHostUriOpeners implements ExtHostUriOpenersShape {
});
}
async $getOpenersForUri(uriComponents: UriComponents, token: CancellationToken): Promise<readonly string[]> {
async $canOpenUri(id: string, uriComponents: UriComponents, token: CancellationToken): Promise<modes.ExternalUriOpenerEnablement> {
const entry = this._openers.get(id);
if (!entry) {
throw new Error(`Unknown opener with id: ${id}`);
}
const uri = URI.revive(uriComponents);
const promises = Array.from(this._openers.entries())
.map(async ([id, { schemes, opener, }]): Promise<string | undefined> => {
if (!schemes.has(uri.scheme)) {
return undefined;
}
try {
if (await opener.canOpenExternalUri(uri, token)) {
return id;
}
} catch (e) {
console.log(e);
// noop
}
return undefined;
});
return (await Promise.all(promises)).filter(handle => typeof handle === 'string') as string[];
const result = await entry.opener.canOpenExternalUri(uri, token);
return result ? result : modes.ExternalUriOpenerEnablement.Disabled;
}
async $openUri(id: string, context: { resolveUri: UriComponents, sourceUri: UriComponents }, token: CancellationToken): Promise<void> {
async $openUri(id: string, context: { resolvedUri: UriComponents, sourceUri: UriComponents }, token: CancellationToken): Promise<void> {
const entry = this._openers.get(id);
if (!entry) {
throw new Error(`Unknown opener id: '${id}'`);
}
return entry.opener.openExternalUri(URI.revive(context.resolveUri), {
return entry.opener.openExternalUri(URI.revive(context.resolvedUri), {
sourceUri: URI.revive(context.sourceUri)
}, token);
}

View file

@ -11,7 +11,7 @@ import { IJSONSchema } from 'vs/base/common/jsonSchema';
export const externalUriOpenersSettingId = 'workbench.externalUriOpeners';
export interface ExternalUriOpenerConfiguration {
readonly hostname: string;
readonly uri: string;
readonly id: string;
}
@ -20,6 +20,18 @@ export const externalUriOpenerIdSchemaAddition: IJSONSchema = {
enum: []
};
const exampleUriPatterns = `
- \`https://microsoft.com\`: Matches this specific domain using https
- \`https://microsoft.com:8080\`: Matches this specific domain on this port using https
- \`https://microsoft.com:*\`: Matches this specific domain on any port using https
- \`https://microsoft.com/foo\`: Matches \`https://microsoft.com/foo\` and \`https://microsoft.com/foo/bar\`, but not \`https://microsoft.com/foobar\` or \`https://microsoft.com/bar\`
- \`https://*.microsoft.com\`: Match all domains ending in \`microsoft.com\` using https
- \`microsoft.com\`: Match this specific domain using either http or https
- \`*.microsoft.com\`: Match all domains ending in \`microsoft.com\` using either http or https
- \`http://192.168.0.1\`: Matches this specific IP using http
- \`http://192.168.0.*\`: Matches all IP's with this prefix using http
- \`*\`: Match all domains using either http or https`;
export const externalUriOpenersConfigurationNode: IConfigurationNode = {
...workbenchConfigurationNodeBase,
properties: {
@ -30,15 +42,15 @@ export const externalUriOpenersConfigurationNode: IConfigurationNode = {
type: 'object',
defaultSnippets: [{
body: {
'hostname': '$1',
'uri': '$1',
'id': '$2'
}
}],
required: ['hostname', 'id'],
required: ['uri', 'id'],
properties: {
'hostname': {
'uri': {
type: 'string',
description: nls.localize('externalUriOpeners.hostname', "The hostname of sites the opener applies to."),
markdownDescription: nls.localize('externalUriOpeners.uri', "Uri pattern that the opener applies to. Example patterns: \n{0}", exampleUriPatterns),
},
'id': {
anyOf: [

View file

@ -3,28 +3,35 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { firstOrDefault } from 'vs/base/common/arrays';
import { CancellationToken } from 'vs/base/common/cancellation';
import { Iterable } from 'vs/base/common/iterator';
import { Disposable, IDisposable } from 'vs/base/common/lifecycle';
import { LinkedList } from 'vs/base/common/linkedList';
import { URI } from 'vs/base/common/uri';
import * as modes from 'vs/editor/common/modes';
import * as nls from 'vs/nls';
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
import { createDecorator } from 'vs/platform/instantiation/common/instantiation';
import { IExternalOpener, IOpenerService } from 'vs/platform/opener/common/opener';
import { IQuickInputService, IQuickPickItem, IQuickPickSeparator } from 'vs/platform/quickinput/common/quickInput';
import { ExternalUriOpenerConfiguration, externalUriOpenersSettingId } from 'vs/workbench/contrib/externalUriOpener/common/configuration';
import { testUrlMatchesGlob } from 'vs/workbench/contrib/url/common/urlGlob';
import { IPreferencesService } from 'vs/workbench/services/preferences/common/preferences';
export const IExternalUriOpenerService = createDecorator<IExternalUriOpenerService>('externalUriOpenerService');
export interface ExternalOpenerEntry extends IExternalOpener {
readonly id: string;
readonly label: string;
}
export interface IExternalOpenerProvider {
provideExternalOpeners(resource: URI | string): Promise<readonly ExternalOpenerEntry[]>;
getOpeners(targetUri: URI): AsyncIterable<IExternalUriOpener>;
}
export interface IExternalUriOpener {
readonly id: string;
readonly label: string;
canOpen(uri: URI, token: CancellationToken): Promise<modes.ExternalUriOpenerEnablement>;
openExternalUri(uri: URI, ctx: { sourceUri: URI }, token: CancellationToken): Promise<boolean>;
}
export interface IExternalUriOpenerService {
@ -40,7 +47,7 @@ export class ExternalUriOpenerService extends Disposable implements IExternalUri
public readonly _serviceBrand: undefined;
private readonly _externalOpenerProviders = new LinkedList<IExternalOpenerProvider>();
private readonly _providers = new LinkedList<IExternalOpenerProvider>();
constructor(
@IOpenerService openerService: IOpenerService,
@ -53,44 +60,89 @@ export class ExternalUriOpenerService extends Disposable implements IExternalUri
}
registerExternalOpenerProvider(provider: IExternalOpenerProvider): IDisposable {
const remove = this._externalOpenerProviders.push(provider);
const remove = this._providers.push(provider);
return { dispose: remove };
}
async openExternal(href: string): Promise<boolean> {
async openExternal(href: string, ctx: { sourceUri: URI }, token: CancellationToken): Promise<boolean> {
const targetUri = typeof href === 'string' ? URI.parse(href) : href;
const openers: ExternalOpenerEntry[] = [];
for (const provider of this._externalOpenerProviders) {
openers.push(...(await provider.provideExternalOpeners(targetUri)));
}
const allOpeners = new Map<string, IExternalUriOpener>();
await Promise.all(Iterable.map(this._providers, async (provider) => {
for await (const opener of provider.getOpeners(targetUri)) {
allOpeners.set(opener.id, opener);
}
}));
if (openers.length === 0) {
if (allOpeners.size === 0) {
return false;
}
const authority = targetUri.authority;
// First check to see if we have a configured opener
const configuredOpener = this.getConfiguredOpenerForUri(allOpeners, targetUri);
if (configuredOpener) {
return configuredOpener.openExternalUri(targetUri, ctx, token);
}
// Then check to see if there is a valid opener
const validOpeners: Array<{ opener: IExternalUriOpener, preferred: boolean }> = [];
await Promise.all(Array.from(allOpeners.values()).map(async opener => {
switch (await opener.canOpen(targetUri, token)) {
case modes.ExternalUriOpenerEnablement.Enabled:
validOpeners.push({ opener, preferred: false });
break;
case modes.ExternalUriOpenerEnablement.Preferred:
validOpeners.push({ opener, preferred: true });
break;
}
}));
if (validOpeners.length === 0) {
return false;
}
// See if we have a preferred opener first
const preferred = firstOrDefault(validOpeners.filter(x => x.preferred));
if (preferred) {
return preferred.opener.openExternalUri(targetUri, ctx, token);
}
// Otherwise prompt
return this.showOpenerPrompt(validOpeners, targetUri, ctx, token);
}
private getConfiguredOpenerForUri(openers: Map<string, IExternalUriOpener>, targetUri: URI): IExternalUriOpener | undefined {
const config = this.configurationService.getValue<readonly ExternalUriOpenerConfiguration[]>(externalUriOpenersSettingId) || [];
for (const entry of config) {
if (entry.hostname === authority) {
const opener = openers.find(opener => opener.id === entry.id);
if (opener) {
return opener.openExternal(href);
for (const { id, uri } of config) {
const entry = openers.get(id);
if (entry) {
if (testUrlMatchesGlob(targetUri.toString(), uri)) {
// Skip the `canOpen` check here since the opener was specifically requested.
return entry;
}
}
}
return undefined;
}
type PickItem = IQuickPickItem & { opener?: IExternalOpener | 'configureDefault' };
const items: Array<PickItem | IQuickPickSeparator> = openers.map((opener, i): PickItem => {
private async showOpenerPrompt(
openers: ReadonlyArray<{ opener: IExternalUriOpener, preferred: boolean }>,
targetUri: URI,
ctx: { sourceUri: URI },
token: CancellationToken
): Promise<boolean> {
type PickItem = IQuickPickItem & { opener?: IExternalUriOpener | 'configureDefault' };
const items: Array<PickItem | IQuickPickSeparator> = openers.map((entry): PickItem => {
return {
label: opener.label,
opener: opener
label: entry.opener.label,
opener: entry.opener
};
});
items.push(
{
label: 'Default',
label: nls.localize('selectOpenerDefaultLabel', 'Default external uri opener'),
opener: undefined
},
{ type: 'separator' },
@ -116,7 +168,7 @@ export class ExternalUriOpenerService extends Disposable implements IExternalUri
});
return true;
} else {
return picked.opener.openExternal(href);
return picked.opener.openExternalUri(targetUri, ctx, token);
}
}
}

View file

@ -0,0 +1,128 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as assert from 'assert';
import { CancellationToken } from 'vs/base/common/cancellation';
import { Disposable } from 'vs/base/common/lifecycle';
import { URI } from 'vs/base/common/uri';
import { ExternalUriOpenerEnablement } from 'vs/editor/common/modes';
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
import { TestConfigurationService } from 'vs/platform/configuration/test/common/testConfigurationService';
import { TestInstantiationService } from 'vs/platform/instantiation/test/common/instantiationServiceMock';
import { IOpenerService } from 'vs/platform/opener/common/opener';
import { IPickOptions, IQuickInputService, IQuickPickItem, QuickPickInput } from 'vs/platform/quickinput/common/quickInput';
import { ExternalUriOpenerService, IExternalOpenerProvider, IExternalUriOpener } from 'vs/workbench/contrib/externalUriOpener/common/externalUriOpenerService';
class MockQuickInputService implements Partial<IQuickInputService>{
constructor(
private readonly pickIndex: number
) { }
public pick<T extends IQuickPickItem>(picks: Promise<QuickPickInput<T>[]> | QuickPickInput<T>[], options?: IPickOptions<T> & { canPickMany: true }, token?: CancellationToken): Promise<T[]>;
public pick<T extends IQuickPickItem>(picks: Promise<QuickPickInput<T>[]> | QuickPickInput<T>[], options?: IPickOptions<T> & { canPickMany: false }, token?: CancellationToken): Promise<T>;
public async pick<T extends IQuickPickItem>(picks: Promise<QuickPickInput<T>[]> | QuickPickInput<T>[], options?: Omit<IPickOptions<T>, 'canPickMany'>, token?: CancellationToken): Promise<T | undefined> {
const resolvedPicks = await picks;
const item = resolvedPicks[this.pickIndex];
if (item.type === 'separator') {
return undefined;
}
return item;
}
}
suite('ExternalUriOpenerService', () => {
let instantiationService: TestInstantiationService;
setup(() => {
instantiationService = new TestInstantiationService();
instantiationService.stub(IConfigurationService, new TestConfigurationService());
instantiationService.stub(IOpenerService, {
registerExternalOpener: () => { return Disposable.None; }
});
});
test('Should not open if there are no openers', async () => {
const externalUriOpenerService: ExternalUriOpenerService = instantiationService.createInstance(ExternalUriOpenerService);
externalUriOpenerService.registerExternalOpenerProvider(new class implements IExternalOpenerProvider {
async *getOpeners(_targetUri: URI): AsyncGenerator<IExternalUriOpener> {
// noop
}
});
const uri = URI.parse('http://contoso.com');
const didOpen = await externalUriOpenerService.openExternal(uri.toString(), { sourceUri: uri }, CancellationToken.None);
assert.strictEqual(didOpen, false);
});
test('Should prompt if there is at least one enabled opener', async () => {
instantiationService.stub(IQuickInputService, new MockQuickInputService(0));
const externalUriOpenerService: ExternalUriOpenerService = instantiationService.createInstance(ExternalUriOpenerService);
let openedWithEnabled = false;
externalUriOpenerService.registerExternalOpenerProvider(new class implements IExternalOpenerProvider {
async *getOpeners(_targetUri: URI): AsyncGenerator<IExternalUriOpener> {
yield {
id: 'disabled-id',
label: 'disabled',
canOpen: async () => ExternalUriOpenerEnablement.Disabled,
openExternalUri: async () => true,
};
yield {
id: 'enabled-id',
label: 'enabled',
canOpen: async () => ExternalUriOpenerEnablement.Enabled,
openExternalUri: async () => {
openedWithEnabled = true;
return true;
}
};
}
});
const uri = URI.parse('http://contoso.com');
const didOpen = await externalUriOpenerService.openExternal(uri.toString(), { sourceUri: uri }, CancellationToken.None);
assert.strictEqual(didOpen, true);
assert.strictEqual(openedWithEnabled, true);
});
test('Should automatically pick single preferred opener without prompt', async () => {
const externalUriOpenerService: ExternalUriOpenerService = instantiationService.createInstance(ExternalUriOpenerService);
let openedWithPreferred = false;
externalUriOpenerService.registerExternalOpenerProvider(new class implements IExternalOpenerProvider {
async *getOpeners(_targetUri: URI): AsyncGenerator<IExternalUriOpener> {
yield {
id: 'other-id',
label: 'other',
canOpen: async () => ExternalUriOpenerEnablement.Enabled,
openExternalUri: async () => {
return true;
}
};
yield {
id: 'preferred-id',
label: 'preferred',
canOpen: async () => ExternalUriOpenerEnablement.Preferred,
openExternalUri: async () => {
openedWithPreferred = true;
return true;
}
};
}
});
const uri = URI.parse('http://contoso.com');
const didOpen = await externalUriOpenerService.openExternal(uri.toString(), { sourceUri: uri }, CancellationToken.None);
assert.strictEqual(didOpen, true);
assert.strictEqual(openedWithPreferred, true);
});
});

View file

@ -22,6 +22,7 @@ import { INotificationService } from 'vs/platform/notification/common/notificati
import { IdleValue } from 'vs/base/common/async';
import { IAuthenticationService } from 'vs/workbench/services/authentication/browser/authenticationService';
import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace';
import { testUrlMatchesGlob } from 'vs/workbench/contrib/url/common/urlGlob';
type TrustedDomainsDialogActionClassification = {
action: { classification: 'SystemMetaData', purpose: 'FeatureInsight' };
@ -216,94 +217,10 @@ export function isURLDomainTrusted(url: URI, trustedDomains: string[]) {
return true;
}
if (isTrusted(url.toString(), trustedDomains[i])) {
if (testUrlMatchesGlob(url.toString(), trustedDomains[i])) {
return true;
}
}
return false;
}
export const isTrusted = (url: string, trustedURL: string): boolean => {
const normalize = (url: string) => url.replace(/\/+$/, '');
trustedURL = normalize(trustedURL);
url = normalize(url);
const memo = Array.from({ length: url.length + 1 }).map(() =>
Array.from({ length: trustedURL.length + 1 }).map(() => undefined),
);
if (/^[^./:]*:\/\//.test(trustedURL)) {
return doURLMatch(memo, url, trustedURL, 0, 0);
}
const scheme = /^(https?):\/\//.exec(url)?.[1];
if (scheme) {
return doURLMatch(memo, url, `${scheme}://${trustedURL}`, 0, 0);
}
return false;
};
const doURLMatch = (
memo: (boolean | undefined)[][],
url: string,
trustedURL: string,
urlOffset: number,
trustedURLOffset: number,
): boolean => {
if (memo[urlOffset]?.[trustedURLOffset] !== undefined) {
return memo[urlOffset][trustedURLOffset]!;
}
const options = [];
// Endgame.
// Fully exact match
if (urlOffset === url.length) {
return trustedURLOffset === trustedURL.length;
}
// Some path remaining in url
if (trustedURLOffset === trustedURL.length) {
const remaining = url.slice(urlOffset);
return remaining[0] === '/';
}
if (url[urlOffset] === trustedURL[trustedURLOffset]) {
// Exact match.
options.push(doURLMatch(memo, url, trustedURL, urlOffset + 1, trustedURLOffset + 1));
}
if (trustedURL[trustedURLOffset] + trustedURL[trustedURLOffset + 1] === '*.') {
// Any subdomain match. Either consume one thing that's not a / or : and don't advance base or consume nothing and do.
if (!['/', ':'].includes(url[urlOffset])) {
options.push(doURLMatch(memo, url, trustedURL, urlOffset + 1, trustedURLOffset));
}
options.push(doURLMatch(memo, url, trustedURL, urlOffset, trustedURLOffset + 2));
}
if (trustedURL[trustedURLOffset] === '*') {
// Any match. Either consume one thing and don't advance base or consume nothing and do.
if (urlOffset + 1 === url.length) {
// If we're at the end of the input url consume one from both.
options.push(doURLMatch(memo, url, trustedURL, urlOffset + 1, trustedURLOffset + 1));
} else {
options.push(doURLMatch(memo, url, trustedURL, urlOffset + 1, trustedURLOffset));
}
options.push(doURLMatch(memo, url, trustedURL, urlOffset, trustedURLOffset + 1));
}
if (trustedURL[trustedURLOffset] + trustedURL[trustedURLOffset + 1] === ':*') {
// any port match. Consume a port if it exists otherwise nothing. Always comsume the base.
if (url[urlOffset] === ':') {
let endPortIndex = urlOffset + 1;
do { endPortIndex++; } while (/[0-9]/.test(url[endPortIndex]));
options.push(doURLMatch(memo, url, trustedURL, endPortIndex, trustedURLOffset + 2));
} else {
options.push(doURLMatch(memo, url, trustedURL, urlOffset, trustedURLOffset + 2));
}
}
return (memo[urlOffset][trustedURLOffset] = options.some(a => a === true));
};

View file

@ -0,0 +1,88 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
export const testUrlMatchesGlob = (url: string, globUrl: string): boolean => {
const normalize = (url: string) => url.replace(/\/+$/, '');
globUrl = normalize(globUrl);
url = normalize(url);
const memo = Array.from({ length: url.length + 1 }).map(() =>
Array.from({ length: globUrl.length + 1 }).map(() => undefined),
);
if (/^[^./:]*:\/\//.test(globUrl)) {
return doUrlMatch(memo, url, globUrl, 0, 0);
}
const scheme = /^(https?):\/\//.exec(url)?.[1];
if (scheme) {
return doUrlMatch(memo, url, `${scheme}://${globUrl}`, 0, 0);
}
return false;
};
const doUrlMatch = (
memo: (boolean | undefined)[][],
url: string,
globUrl: string,
urlOffset: number,
globUrlOffset: number,
): boolean => {
if (memo[urlOffset]?.[globUrlOffset] !== undefined) {
return memo[urlOffset][globUrlOffset]!;
}
const options = [];
// Endgame.
// Fully exact match
if (urlOffset === url.length) {
return globUrlOffset === globUrl.length;
}
// Some path remaining in url
if (globUrlOffset === globUrl.length) {
const remaining = url.slice(urlOffset);
return remaining[0] === '/';
}
if (url[urlOffset] === globUrl[globUrlOffset]) {
// Exact match.
options.push(doUrlMatch(memo, url, globUrl, urlOffset + 1, globUrlOffset + 1));
}
if (globUrl[globUrlOffset] + globUrl[globUrlOffset + 1] === '*.') {
// Any subdomain match. Either consume one thing that's not a / or : and don't advance base or consume nothing and do.
if (!['/', ':'].includes(url[urlOffset])) {
options.push(doUrlMatch(memo, url, globUrl, urlOffset + 1, globUrlOffset));
}
options.push(doUrlMatch(memo, url, globUrl, urlOffset, globUrlOffset + 2));
}
if (globUrl[globUrlOffset] === '*') {
// Any match. Either consume one thing and don't advance base or consume nothing and do.
if (urlOffset + 1 === url.length) {
// If we're at the end of the input url consume one from both.
options.push(doUrlMatch(memo, url, globUrl, urlOffset + 1, globUrlOffset + 1));
} else {
options.push(doUrlMatch(memo, url, globUrl, urlOffset + 1, globUrlOffset));
}
options.push(doUrlMatch(memo, url, globUrl, urlOffset, globUrlOffset + 1));
}
if (globUrl[globUrlOffset] + globUrl[globUrlOffset + 1] === ':*') {
// any port match. Consume a port if it exists otherwise nothing. Always comsume the base.
if (url[urlOffset] === ':') {
let endPortIndex = urlOffset + 1;
do { endPortIndex++; } while (/[0-9]/.test(url[endPortIndex]));
options.push(doUrlMatch(memo, url, globUrl, endPortIndex, globUrlOffset + 2));
} else {
options.push(doUrlMatch(memo, url, globUrl, urlOffset, globUrlOffset + 2));
}
}
return (memo[urlOffset][globUrlOffset] = options.some(a => a === true));
};