mirror of
https://github.com/Microsoft/vscode
synced 2024-10-29 03:14:28 +00:00
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:
parent
5b1e59c636
commit
5d6cba5cbc
15 changed files with 397 additions and 172 deletions
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -1876,3 +1876,13 @@ export interface ITokenizationRegistry {
|
|||
* @internal
|
||||
*/
|
||||
export const TokenizationRegistry = new TokenizationRegistryImpl();
|
||||
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export enum ExternalUriOpenerEnablement {
|
||||
Disabled,
|
||||
Enabled,
|
||||
Preferred
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
21
src/vs/vscode.proposed.d.ts
vendored
21
src/vs/vscode.proposed.d.ts
vendored
|
@ -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.
|
||||
|
|
|
@ -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()));
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -2976,3 +2976,9 @@ export type RequiredTestItem = {
|
|||
|
||||
|
||||
//#endregion
|
||||
|
||||
export enum ExternalUriOpenerEnablement {
|
||||
Disabled = 0,
|
||||
Enabled = 1,
|
||||
Preferred = 2
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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: [
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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));
|
||||
};
|
||||
|
|
88
src/vs/workbench/contrib/url/common/urlGlob.ts
Normal file
88
src/vs/workbench/contrib/url/common/urlGlob.ts
Normal 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));
|
||||
};
|
Loading…
Reference in a new issue