未验证 提交 9be3eb53 编写于 作者: P Pine 提交者: GitHub

Merge pull request #79538 from microsoft/pine/openerservice

Refactor OpenerService. Fix #79487
......@@ -4,41 +4,39 @@
*--------------------------------------------------------------------------------------------*/
import * as dom from 'vs/base/browser/dom';
import { Disposable, IDisposable } from 'vs/base/common/lifecycle';
import { LinkedList } from 'vs/base/common/linkedList';
import { parse } from 'vs/base/common/marshalling';
import { Schemas } from 'vs/base/common/network';
import * as resources from 'vs/base/common/resources';
import { equalsIgnoreCase } from 'vs/base/common/strings';
import { URI } from 'vs/base/common/uri';
import { ICodeEditorService } from 'vs/editor/browser/services/codeEditorService';
import { CommandsRegistry, ICommandService } from 'vs/platform/commands/common/commands';
import { IOpenerService, IOpener } from 'vs/platform/opener/common/opener';
import { equalsIgnoreCase } from 'vs/base/common/strings';
import { IDisposable } from 'vs/base/common/lifecycle';
import { LinkedList } from 'vs/base/common/linkedList';
import { IDialogService } from 'vs/platform/dialogs/common/dialogs';
import { localize } from 'vs/nls';
import { IProductService } from 'vs/platform/product/common/product';
import { IStorageService, StorageScope } from 'vs/platform/storage/common/storage';
import Severity from 'vs/base/common/severity';
import { ServiceIdentifier } from 'vs/platform/instantiation/common/instantiation';
import { IOpener, IOpenerService, IValidator } from 'vs/platform/opener/common/opener';
export class OpenerService implements IOpenerService {
export class OpenerService extends Disposable implements IOpenerService {
_serviceBrand!: ServiceIdentifier<any>;
private readonly _opener = new LinkedList<IOpener>();
private readonly _openers = new LinkedList<IOpener>();
private readonly _validators = new LinkedList<IValidator>();
constructor(
@ICodeEditorService private readonly _editorService: ICodeEditorService,
@ICommandService private readonly _commandService: ICommandService,
@IStorageService private readonly _storageService: IStorageService,
@IDialogService private readonly _dialogService: IDialogService,
@IProductService private readonly _productService: IProductService
) {
//
super();
}
registerOpener(opener: IOpener): IDisposable {
const remove = this._opener.push(opener);
const remove = this._openers.push(opener);
return { dispose: remove };
}
registerValidator(validator: IValidator): IDisposable {
const remove = this._validators.push(validator);
return { dispose: remove };
}
......@@ -47,8 +45,16 @@ export class OpenerService implements IOpenerService {
if (!resource.scheme) {
return Promise.resolve(false);
}
// check with contributed validators
for (const validator of this._validators.toArray()) {
if (!(await validator.shouldOpen(resource))) {
return false;
}
}
// check with contributed openers
for (const opener of this._opener.toArray()) {
for (const opener of this._openers.toArray()) {
const handled = await opener.open(resource, options);
if (handled) {
return true;
......@@ -60,7 +66,7 @@ export class OpenerService implements IOpenerService {
private _doOpen(resource: URI, options?: { openToSide?: boolean, openExternal?: boolean }): Promise<boolean> {
const { scheme, authority, path, query, fragment } = resource;
const { scheme, path, query, fragment } = resource;
if (equalsIgnoreCase(scheme, Schemas.mailto) || (options && options.openExternal)) {
// open default mail application
......@@ -68,48 +74,8 @@ export class OpenerService implements IOpenerService {
}
if (equalsIgnoreCase(scheme, Schemas.http) || equalsIgnoreCase(scheme, Schemas.https)) {
let trustedDomains: string[] = ['https://code.visualstudio.com'];
try {
const trustedDomainsSrc = this._storageService.get('http.trustedDomains', StorageScope.GLOBAL);
if (trustedDomainsSrc) {
trustedDomains = JSON.parse(trustedDomainsSrc);
}
} catch (err) { }
const domainToOpen = `${scheme}://${authority}`;
if (isDomainTrusted(domainToOpen, trustedDomains)) {
return this._doOpenExternal(resource);
} else {
return this._dialogService.show(
Severity.Info,
localize(
'openExternalLinkAt',
'Do you want {0} to open the external website?\n{1}',
this._productService.nameShort,
resource.toString(true)
),
[
localize('openLink', 'Open Link'),
localize('cancel', 'Cancel'),
localize('configureTrustedDomains', 'Configure Trusted Domains')
],
{
cancelId: 1
}).then((choice) => {
if (choice === 0) {
return this._doOpenExternal(resource);
} else if (choice === 2) {
return this._commandService.executeCommand('workbench.action.configureTrustedDomains', domainToOpen).then((pickedDomains: string[]) => {
if (pickedDomains.indexOf(domainToOpen) !== -1) {
return this._doOpenExternal(resource);
}
return Promise.resolve(false);
});
}
return Promise.resolve(false);
});
}
// open link in default browser
return this._doOpenExternal(resource);
} else if (equalsIgnoreCase(scheme, Schemas.command)) {
// run command or bail out if command isn't known
if (!CommandsRegistry.getCommand(path)) {
......@@ -158,22 +124,8 @@ export class OpenerService implements IOpenerService {
return Promise.resolve(true);
}
}
/**
* Check whether a domain like https://www.microsoft.com matches
* the list of trusted domains.
*/
function isDomainTrusted(domain: string, trustedDomains: string[]) {
for (let i = 0; i < trustedDomains.length; i++) {
if (trustedDomains[i] === '*') {
return true;
}
if (trustedDomains[i] === domain) {
return true;
}
dispose() {
this._validators.clear();
}
return false;
}
......@@ -38,9 +38,6 @@ import { INotificationService } from 'vs/platform/notification/common/notificati
import { IOpenerService } from 'vs/platform/opener/common/opener';
import { IAccessibilityService } from 'vs/platform/accessibility/common/accessibility';
import { clearAllFontInfos } from 'vs/editor/browser/config/configuration';
import { IDialogService } from 'vs/platform/dialogs/common/dialogs';
import { IProductService } from 'vs/platform/product/common/product';
import { IStorageService } from 'vs/platform/storage/common/storage';
type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
......@@ -54,13 +51,7 @@ function withAllStandaloneServices<T extends editorCommon.IEditor>(domElement: H
}
if (!services.has(IOpenerService)) {
services.set(IOpenerService, new OpenerService(
services.get(ICodeEditorService),
services.get(ICommandService),
services.get(IStorageService),
services.get(IDialogService),
services.get(IProductService)
));
services.set(IOpenerService, new OpenerService(services.get(ICodeEditorService), services.get(ICommandService)));
}
let result = callback(services);
......
......@@ -7,18 +7,13 @@ import { URI } from 'vs/base/common/uri';
import { OpenerService } from 'vs/editor/browser/services/openerService';
import { TestCodeEditorService } from 'vs/editor/test/browser/editorTestServices';
import { CommandsRegistry, ICommandService, NullCommandService } from 'vs/platform/commands/common/commands';
import { deepClone } from 'vs/base/common/objects';
import { IDialogService } from 'vs/platform/dialogs/common/dialogs';
import { IProductService } from 'vs/platform/product/common/product';
import { IStorageService } from 'vs/platform/storage/common/storage';
suite('OpenerService', function () {
const editorService = new TestCodeEditorService();
let lastCommand: { id: string, args: any[] } | undefined;
let lastCommand: { id: string; args: any[] } | undefined;
const commandService = new class implements ICommandService {
const commandService = new (class implements ICommandService {
_serviceBrand: any;
onWillExecuteCommand = () => ({ dispose: () => { } });
onDidExecuteCommand = () => ({ dispose: () => { } });
......@@ -26,80 +21,20 @@ suite('OpenerService', function () {
lastCommand = { id, args };
return Promise.resolve(undefined);
}
};
function getStorageService(trustedDomainsSetting: string[]) {
let _settings = deepClone(trustedDomainsSetting);
return new class implements IStorageService {
get = () => JSON.stringify(_settings);
store = (key: string, val: string) => _settings = JSON.parse(val);
// Don't care
_serviceBrand: any;
onDidChangeStorage = () => ({ dispose: () => { } });
onWillSaveState = () => ({ dispose: () => { } });
getBoolean = () => true;
getNumber = () => 0;
remove = () => { };
logStorage = () => { };
};
}
function getDialogService() {
return new class implements IDialogService {
_showInvoked = 0;
show = () => {
this._showInvoked++;
return Promise.resolve({} as any);
}
get confirmInvoked() { return this._showInvoked; }
// Don't care
_serviceBrand: any;
confirm = () => {
return Promise.resolve({} as any);
}
};
}
function getProductService(): IProductService {
return new class {
nameShort: 'VS Code';
_serviceBrand: any;
} as IProductService;
}
})();
setup(function () {
lastCommand = undefined;
});
test('delegate to editorService, scheme:///fff', function () {
const openerService = new OpenerService(
editorService,
NullCommandService,
getStorageService([]),
getDialogService(),
getProductService()
);
const openerService = new OpenerService(editorService, NullCommandService);
openerService.open(URI.parse('another:///somepath'));
assert.equal(editorService.lastInput!.options!.selection, undefined);
});
test('delegate to editorService, scheme:///fff#L123', function () {
const openerService = new OpenerService(
editorService,
NullCommandService,
getStorageService([]),
getDialogService(),
getProductService()
);
const openerService = new OpenerService(editorService, NullCommandService);
openerService.open(URI.parse('file:///somepath#L23'));
assert.equal(editorService.lastInput!.options!.selection!.startLineNumber, 23);
......@@ -121,14 +56,7 @@ suite('OpenerService', function () {
});
test('delegate to editorService, scheme:///fff#123,123', function () {
const openerService = new OpenerService(
editorService,
NullCommandService,
getStorageService([]),
getDialogService(),
getProductService()
);
const openerService = new OpenerService(editorService, NullCommandService);
openerService.open(URI.parse('file:///somepath#23'));
assert.equal(editorService.lastInput!.options!.selection!.startLineNumber, 23);
......@@ -146,14 +74,7 @@ suite('OpenerService', function () {
});
test('delegate to commandsService, command:someid', function () {
const openerService = new OpenerService(
editorService,
commandService,
getStorageService([]),
getDialogService(),
getProductService()
);
const openerService = new OpenerService(editorService, commandService);
const id = `aCommand${Math.random()}`;
CommandsRegistry.registerCommand(id, function () { });
......@@ -174,69 +95,107 @@ suite('OpenerService', function () {
assert.equal(lastCommand!.args[1], true);
});
test('links are protected by dialog.show', function () {
const dialogService = getDialogService();
const openerService = new OpenerService(
editorService,
commandService,
getStorageService([]),
dialogService,
getProductService()
);
openerService.open(URI.parse('https://www.microsoft.com'));
assert.equal(dialogService.confirmInvoked, 1);
test('links are protected by validators', async function () {
const openerService = new OpenerService(editorService, commandService);
openerService.registerValidator({ shouldOpen: () => Promise.resolve(false) });
const httpResult = await openerService.open(URI.parse('https://www.microsoft.com'));
const httpsResult = await openerService.open(URI.parse('https://www.microsoft.com'));
assert.equal(httpResult, false);
assert.equal(httpsResult, false);
});
test('links on the whitelisted domains can be opened without dialog.show', function () {
const dialogService = getDialogService();
const openerService = new OpenerService(
editorService,
commandService,
getStorageService(['https://microsoft.com']),
dialogService,
getProductService()
);
openerService.open(URI.parse('https://microsoft.com'));
openerService.open(URI.parse('https://microsoft.com/'));
openerService.open(URI.parse('https://microsoft.com/en-us/'));
openerService.open(URI.parse('https://microsoft.com/en-us/?foo=bar'));
openerService.open(URI.parse('https://microsoft.com/en-us/?foo=bar#baz'));
assert.equal(dialogService.confirmInvoked, 0);
test('links validated by validators go to openers', async function () {
const openerService = new OpenerService(editorService, commandService);
openerService.registerValidator({ shouldOpen: () => Promise.resolve(true) });
let openCount = 0;
openerService.registerOpener({
open: (resource: URI) => {
openCount++;
return Promise.resolve(true);
}
});
await openerService.open(URI.parse('http://microsoft.com'));
assert.equal(openCount, 1);
await openerService.open(URI.parse('https://microsoft.com'));
assert.equal(openCount, 2);
});
test('variations of links are protected by dialog confirmation', function () {
const dialogService = getDialogService();
const openerService = new OpenerService(
editorService,
commandService,
getStorageService(['https://microsoft.com']),
dialogService,
getProductService()
);
test('links validated by multiple validators', async function () {
const openerService = new OpenerService(editorService, commandService);
openerService.open(URI.parse('http://microsoft.com'));
openerService.open(URI.parse('https://www.microsoft.com'));
let v1 = 0;
openerService.registerValidator({
shouldOpen: () => {
v1++;
return Promise.resolve(true);
}
});
assert.equal(dialogService.confirmInvoked, 2);
let v2 = 0;
openerService.registerValidator({
shouldOpen: () => {
v2++;
return Promise.resolve(true);
}
});
let openCount = 0;
openerService.registerOpener({
open: (resource: URI) => {
openCount++;
return Promise.resolve(true);
}
});
await openerService.open(URI.parse('http://microsoft.com'));
assert.equal(openCount, 1);
assert.equal(v1, 1);
assert.equal(v2, 1);
await openerService.open(URI.parse('https://microsoft.com'));
assert.equal(openCount, 2);
assert.equal(v1, 2);
assert.equal(v2, 2);
});
test('* removes all link protection', function () {
const dialogService = getDialogService();
const openerService = new OpenerService(
editorService,
commandService,
getStorageService(['*']),
dialogService,
getProductService()
);
openerService.open(URI.parse('https://code.visualstudio.com/'));
openerService.open(URI.parse('https://www.microsoft.com'));
openerService.open(URI.parse('https://www.github.com'));
assert.equal(dialogService.confirmInvoked, 0);
test('links invalidated by first validator do not continue validating', async function () {
const openerService = new OpenerService(editorService, commandService);
let v1 = 0;
openerService.registerValidator({
shouldOpen: () => {
v1++;
return Promise.resolve(false);
}
});
let v2 = 0;
openerService.registerValidator({
shouldOpen: () => {
v2++;
return Promise.resolve(true);
}
});
let openCount = 0;
openerService.registerOpener({
open: (resource: URI) => {
openCount++;
return Promise.resolve(true);
}
});
await openerService.open(URI.parse('http://microsoft.com'));
assert.equal(openCount, 0);
assert.equal(v1, 1);
assert.equal(v2, 0);
await openerService.open(URI.parse('https://microsoft.com'));
assert.equal(openCount, 0);
assert.equal(v1, 2);
assert.equal(v2, 0);
});
});
......@@ -14,6 +14,10 @@ export interface IOpener {
open(resource: URI, options?: { openExternal?: boolean }): Promise<boolean>;
}
export interface IValidator {
shouldOpen(resource: URI): Promise<boolean>;
}
export interface IOpenerService {
_serviceBrand: any;
......@@ -23,6 +27,12 @@ export interface IOpenerService {
*/
registerOpener(opener: IOpener): IDisposable;
/**
* Register a participant that can validate if the URI resource be opened.
* validators are run before openers.
*/
registerValidator(validator: IValidator): IDisposable;
/**
* Opens a resource, like a webaddress, a document uri, or executes command.
*
......@@ -36,5 +46,6 @@ export interface IOpenerService {
export const NullOpenerService: IOpenerService = Object.freeze({
_serviceBrand: undefined,
registerOpener() { return { dispose() { } }; },
registerValidator() { return { dispose() { } }; },
open() { return Promise.resolve(false); },
});
......@@ -14,17 +14,24 @@ import { Action } from 'vs/base/common/actions';
import { IStorageService, StorageScope } from 'vs/platform/storage/common/storage';
import { CommandsRegistry } from 'vs/platform/commands/common/commands';
import { ServicesAccessor } from 'vs/platform/instantiation/common/instantiation';
import { IWorkbenchContribution, IWorkbenchContributionsRegistry, Extensions as WorkbenchExtensions } from 'vs/workbench/common/contributions';
import { LifecyclePhase } from 'vs/platform/lifecycle/common/lifecycle';
import { IDialogService } from 'vs/platform/dialogs/common/dialogs';
import { IProductService } from 'vs/platform/product/common/product';
import { IOpenerService } from 'vs/platform/opener/common/opener';
import { equalsIgnoreCase } from 'vs/base/common/strings';
import { Schemas } from 'vs/base/common/network';
import Severity from 'vs/base/common/severity';
export class OpenUrlAction extends Action {
static readonly ID = 'workbench.action.url.openUrl';
static readonly LABEL = localize('openUrl', "Open URL");
static readonly LABEL = localize('openUrl', 'Open URL');
constructor(
id: string,
label: string,
@IURLService private readonly urlService: IURLService,
@IQuickInputService private readonly quickInputService: IQuickInputService,
@IQuickInputService private readonly quickInputService: IQuickInputService
) {
super(id, label);
}
......@@ -45,7 +52,7 @@ Registry.as<IWorkbenchActionRegistry>(ActionExtensions.WorkbenchActions).registe
const VSCODE_DOMAIN = 'https://code.visualstudio.com';
const configureTrustedDomainsHandler = (
const configureTrustedDomainsHandler = async (
quickInputService: IQuickInputService,
storageService: IStorageService,
domainToConfigure?: string
......@@ -66,7 +73,7 @@ const configureTrustedDomainsHandler = (
type: 'item',
label: d,
id: d,
picked: true,
picked: true
};
});
......@@ -91,23 +98,24 @@ const configureTrustedDomainsHandler = (
specialQuickPickItems.push(<IQuickPickItem>domainToConfigureItem);
}
const quickPickItems: (IQuickPickItem | IQuickPickSeparator)[] = domainQuickPickItems.length === 0
? specialQuickPickItems
: [...specialQuickPickItems, { type: 'separator' }, ...domainQuickPickItems];
const quickPickItems: (IQuickPickItem | IQuickPickSeparator)[] =
domainQuickPickItems.length === 0
? specialQuickPickItems
: [...specialQuickPickItems, { type: 'separator' }, ...domainQuickPickItems];
return quickInputService.pick(quickPickItems, {
const pickedResult = await quickInputService.pick(quickPickItems, {
canPickMany: true,
activeItem: domainToConfigureItem
}).then(result => {
if (result) {
const pickedDomains = result.map(r => r.id);
storageService.store('http.trustedDomains', JSON.stringify(pickedDomains), StorageScope.GLOBAL);
});
return pickedDomains;
}
if (pickedResult) {
const pickedDomains: string[] = pickedResult.map(r => r.id!);
storageService.store('http.trustedDomains', JSON.stringify(pickedDomains), StorageScope.GLOBAL);
return [];
});
return pickedDomains;
}
return [];
};
const configureTrustedDomainCommand = {
......@@ -131,3 +139,93 @@ MenuRegistry.appendMenuItem(MenuId.CommandPalette, {
title: configureTrustedDomainCommand.description.description
}
});
class OpenerValidatorContributions implements IWorkbenchContribution {
constructor(
@IOpenerService private readonly _openerService: IOpenerService,
@IStorageService private readonly _storageService: IStorageService,
@IDialogService private readonly _dialogService: IDialogService,
@IProductService private readonly _productService: IProductService,
@IQuickInputService private readonly _quickInputService: IQuickInputService
) {
this._openerService.registerValidator({ shouldOpen: r => this.validateLink(r) });
}
async validateLink(resource: URI): Promise<boolean> {
const { scheme, authority } = resource;
if (!equalsIgnoreCase(scheme, Schemas.http) && !equalsIgnoreCase(scheme, Schemas.https)) {
return true;
}
let trustedDomains: string[] = [VSCODE_DOMAIN];
try {
const trustedDomainsSrc = this._storageService.get('http.trustedDomains', StorageScope.GLOBAL);
if (trustedDomainsSrc) {
trustedDomains = JSON.parse(trustedDomainsSrc);
}
} catch (err) { }
const domainToOpen = `${scheme}://${authority}`;
if (isDomainTrusted(domainToOpen, trustedDomains)) {
return true;
} else {
const choice = await this._dialogService.show(
Severity.Info,
localize(
'openExternalLinkAt',
'Do you want {0} to open the external website?\n{1}',
this._productService.nameShort,
resource.toString(true)
),
[
localize('openLink', 'Open Link'),
localize('cancel', 'Cancel'),
localize('configureTrustedDomains', 'Configure Trusted Domains')
],
{
cancelId: 1
}
);
// Open Link
if (choice === 0) {
return true;
}
// Configure Trusted Domains
else if (choice === 2) {
const pickedDomains = await configureTrustedDomainsHandler(this._quickInputService, this._storageService, domainToOpen);
if (pickedDomains.indexOf(domainToOpen) !== -1) {
return true;
}
return false;
}
return false;
}
}
}
Registry.as<IWorkbenchContributionsRegistry>(WorkbenchExtensions.Workbench).registerWorkbenchContribution(
OpenerValidatorContributions,
LifecyclePhase.Restored
);
/**
* Check whether a domain like https://www.microsoft.com matches
* the list of trusted domains.
*/
function isDomainTrusted(domain: string, trustedDomains: string[]) {
for (let i = 0; i < trustedDomains.length; i++) {
if (trustedDomains[i] === '*') {
return true;
}
if (trustedDomains[i] === domain) {
return true;
}
}
return false;
}
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册