diff --git a/extensions/simple-browser/src/extension.ts b/extensions/simple-browser/src/extension.ts index 8ee1f0faaf2..7274a7343b7 100644 --- a/extensions/simple-browser/src/extension.ts +++ b/extensions/simple-browser/src/extension.ts @@ -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; +} diff --git a/src/vs/editor/browser/services/openerService.ts b/src/vs/editor/browser/services/openerService.ts index a968cfad07e..5479ec29bd6 100644 --- a/src/vs/editor/browser/services/openerService.ts +++ b/src/vs/editor/browser/services/openerService.ts @@ -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() { diff --git a/src/vs/editor/common/modes.ts b/src/vs/editor/common/modes.ts index 6a51e49b9f1..35b306d602f 100644 --- a/src/vs/editor/common/modes.ts +++ b/src/vs/editor/common/modes.ts @@ -1876,3 +1876,13 @@ export interface ITokenizationRegistry { * @internal */ export const TokenizationRegistry = new TokenizationRegistryImpl(); + + +/** + * @internal + */ +export enum ExternalUriOpenerEnablement { + Disabled, + Enabled, + Preferred +} diff --git a/src/vs/platform/opener/common/opener.ts b/src/vs/platform/opener/common/opener.ts index c1ea8a6b349..3f96fc90032 100644 --- a/src/vs/platform/opener/common/opener.ts +++ b/src/vs/platform/opener/common/opener.ts @@ -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('openerService'); @@ -46,7 +47,7 @@ export interface IOpener { } export interface IExternalOpener { - openExternal(href: string): Promise; + openExternal(href: string, ctx: { sourceUri: URI }, token: CancellationToken): Promise; dispose?(): void; } diff --git a/src/vs/vscode.proposed.d.ts b/src/vs/vscode.proposed.d.ts index a9849d1eae4..d4f775ccc45 100644 --- a/src/vs/vscode.proposed.d.ts +++ b/src/vs/vscode.proposed.d.ts @@ -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; + canOpenExternalUri(uri: Uri, token: CancellationToken): ProviderResult; /** * Open the given uri. diff --git a/src/vs/workbench/api/browser/mainThreadUriOpeners.ts b/src/vs/workbench/api/browser/mainThreadUriOpeners.ts index f521610aa0d..99e26769eb0 100644 --- a/src/vs/workbench/api/browser/mainThreadUriOpeners.ts +++ b/src/vs/workbench/api/browser/mainThreadUriOpeners.ts @@ -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 { - const targetUri = typeof href === 'string' ? URI.parse(href) : href; + public async *getOpeners(targetUri: URI): AsyncIterable { // 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())); diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index 27c8d06fb5c..232d2734fd9 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -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, diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index c2a8614457f..a5f6d3ed535 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -807,8 +807,8 @@ export interface MainThreadUriOpenersShape extends IDisposable { } export interface ExtHostUriOpenersShape { - $getOpenersForUri(uri: UriComponents, token: CancellationToken): Promise; - $openUri(id: string, context: { resolveUri: UriComponents, sourceUri: UriComponents }, token: CancellationToken): Promise; + $canOpenUri(id: string, uri: UriComponents, token: CancellationToken): Promise; + $openUri(id: string, context: { resolvedUri: UriComponents, sourceUri: UriComponents }, token: CancellationToken): Promise; } export interface ITextSearchComplete { diff --git a/src/vs/workbench/api/common/extHostTypes.ts b/src/vs/workbench/api/common/extHostTypes.ts index a4cd5a326f6..e153af8aa46 100644 --- a/src/vs/workbench/api/common/extHostTypes.ts +++ b/src/vs/workbench/api/common/extHostTypes.ts @@ -2976,3 +2976,9 @@ export type RequiredTestItem = { //#endregion + +export enum ExternalUriOpenerEnablement { + Disabled = 0, + Enabled = 1, + Preferred = 2 +} diff --git a/src/vs/workbench/api/common/extHostUriOpener.ts b/src/vs/workbench/api/common/extHostUriOpener.ts index 7d8115d41e2..eaaaca2088d 100644 --- a/src/vs/workbench/api/common/extHostUriOpener.ts +++ b/src/vs/workbench/api/common/extHostUriOpener.ts @@ -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 { + async $canOpenUri(id: string, uriComponents: UriComponents, token: CancellationToken): Promise { + 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 => { - 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 { + async $openUri(id: string, context: { resolvedUri: UriComponents, sourceUri: UriComponents }, token: CancellationToken): Promise { 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); } diff --git a/src/vs/workbench/contrib/externalUriOpener/common/configuration.ts b/src/vs/workbench/contrib/externalUriOpener/common/configuration.ts index b61f29d6937..fcd27ca5263 100644 --- a/src/vs/workbench/contrib/externalUriOpener/common/configuration.ts +++ b/src/vs/workbench/contrib/externalUriOpener/common/configuration.ts @@ -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: [ diff --git a/src/vs/workbench/contrib/externalUriOpener/common/externalUriOpenerService.ts b/src/vs/workbench/contrib/externalUriOpener/common/externalUriOpenerService.ts index 056e43eef95..75fa9995d8d 100644 --- a/src/vs/workbench/contrib/externalUriOpener/common/externalUriOpenerService.ts +++ b/src/vs/workbench/contrib/externalUriOpener/common/externalUriOpenerService.ts @@ -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('externalUriOpenerService'); -export interface ExternalOpenerEntry extends IExternalOpener { - readonly id: string; - readonly label: string; -} - - export interface IExternalOpenerProvider { - provideExternalOpeners(resource: URI | string): Promise; + getOpeners(targetUri: URI): AsyncIterable; +} + +export interface IExternalUriOpener { + readonly id: string; + readonly label: string; + + canOpen(uri: URI, token: CancellationToken): Promise; + openExternalUri(uri: URI, ctx: { sourceUri: URI }, token: CancellationToken): Promise; } export interface IExternalUriOpenerService { @@ -40,7 +47,7 @@ export class ExternalUriOpenerService extends Disposable implements IExternalUri public readonly _serviceBrand: undefined; - private readonly _externalOpenerProviders = new LinkedList(); + private readonly _providers = new LinkedList(); 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 { + async openExternal(href: string, ctx: { sourceUri: URI }, token: CancellationToken): Promise { 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(); + 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, targetUri: URI): IExternalUriOpener | undefined { const config = this.configurationService.getValue(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 = openers.map((opener, i): PickItem => { + private async showOpenerPrompt( + openers: ReadonlyArray<{ opener: IExternalUriOpener, preferred: boolean }>, + targetUri: URI, + ctx: { sourceUri: URI }, + token: CancellationToken + ): Promise { + type PickItem = IQuickPickItem & { opener?: IExternalUriOpener | 'configureDefault' }; + + const items: Array = 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); } } } diff --git a/src/vs/workbench/contrib/externalUriOpener/test/common/externalUriOpenerService.test.ts b/src/vs/workbench/contrib/externalUriOpener/test/common/externalUriOpenerService.test.ts new file mode 100644 index 00000000000..ab8fc8d7a8a --- /dev/null +++ b/src/vs/workbench/contrib/externalUriOpener/test/common/externalUriOpenerService.test.ts @@ -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{ + + constructor( + private readonly pickIndex: number + ) { } + + public pick(picks: Promise[]> | QuickPickInput[], options?: IPickOptions & { canPickMany: true }, token?: CancellationToken): Promise; + public pick(picks: Promise[]> | QuickPickInput[], options?: IPickOptions & { canPickMany: false }, token?: CancellationToken): Promise; + public async pick(picks: Promise[]> | QuickPickInput[], options?: Omit, 'canPickMany'>, token?: CancellationToken): Promise { + 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 { + // 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 { + 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 { + 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); + }); +}); diff --git a/src/vs/workbench/contrib/url/browser/trustedDomainsValidator.ts b/src/vs/workbench/contrib/url/browser/trustedDomainsValidator.ts index c8f144bdaf3..564cf24f9cb 100644 --- a/src/vs/workbench/contrib/url/browser/trustedDomainsValidator.ts +++ b/src/vs/workbench/contrib/url/browser/trustedDomainsValidator.ts @@ -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)); -}; diff --git a/src/vs/workbench/contrib/url/common/urlGlob.ts b/src/vs/workbench/contrib/url/common/urlGlob.ts new file mode 100644 index 00000000000..8893796290c --- /dev/null +++ b/src/vs/workbench/contrib/url/common/urlGlob.ts @@ -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)); +};