Path: blob/main/src/vs/workbench/services/extensions/browser/extensionUrlHandler.ts
3296 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { localize, localize2 } from '../../../../nls.js';6import { IDisposable, combinedDisposable, toDisposable } from '../../../../base/common/lifecycle.js';7import { URI } from '../../../../base/common/uri.js';8import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';9import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js';10import { createDecorator, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js';11import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';12import { IURLHandler, IURLService, IOpenURLOptions } from '../../../../platform/url/common/url.js';13import { IHostService } from '../../host/browser/host.js';14import { ActivationKind, IExtensionService } from '../common/extensions.js';15import { ExtensionIdentifier } from '../../../../platform/extensions/common/extensions.js';16import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js';17import { IWorkbenchContribution, WorkbenchPhase, registerWorkbenchContribution2 } from '../../../common/contributions.js';18import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js';19import { IQuickInputService, IQuickPickItem } from '../../../../platform/quickinput/common/quickInput.js';20import { IsWebContext } from '../../../../platform/contextkey/common/contextkeys.js';21import { IProductService } from '../../../../platform/product/common/productService.js';22import { disposableWindowInterval } from '../../../../base/browser/dom.js';23import { mainWindow } from '../../../../base/browser/window.js';24import { ICommandService } from '../../../../platform/commands/common/commands.js';25import { isCancellationError } from '../../../../base/common/errors.js';26import { INotificationService } from '../../../../platform/notification/common/notification.js';27import { MarkdownString } from '../../../../base/common/htmlContent.js';28import { equalsIgnoreCase } from '../../../../base/common/strings.js';2930const FIVE_MINUTES = 5 * 60 * 1000;31const THIRTY_SECONDS = 30 * 1000;32const URL_TO_HANDLE = 'extensionUrlHandler.urlToHandle';33const USER_TRUSTED_EXTENSIONS_CONFIGURATION_KEY = 'extensions.confirmedUriHandlerExtensionIds';34const USER_TRUSTED_EXTENSIONS_STORAGE_KEY = 'extensionUrlHandler.confirmedExtensions';3536function isExtensionId(value: string): boolean {37return /^[a-z0-9][a-z0-9\-]*\.[a-z0-9][a-z0-9\-]*$/i.test(value);38}3940class UserTrustedExtensionIdStorage {4142get extensions(): string[] {43const userTrustedExtensionIdsJson = this.storageService.get(USER_TRUSTED_EXTENSIONS_STORAGE_KEY, StorageScope.PROFILE, '[]');4445try {46return JSON.parse(userTrustedExtensionIdsJson);47} catch {48return [];49}50}5152constructor(private storageService: IStorageService) { }5354has(id: string): boolean {55return this.extensions.indexOf(id) > -1;56}5758add(id: string): void {59this.set([...this.extensions, id]);60}6162set(ids: string[]): void {63this.storageService.store(USER_TRUSTED_EXTENSIONS_STORAGE_KEY, JSON.stringify(ids), StorageScope.PROFILE, StorageTarget.MACHINE);64}65}6667export const IExtensionUrlHandler = createDecorator<IExtensionUrlHandler>('extensionUrlHandler');6869export interface IExtensionContributedURLHandler extends IURLHandler {70extensionDisplayName: string;71}7273export interface IExtensionUrlHandler {74readonly _serviceBrand: undefined;75registerExtensionHandler(extensionId: ExtensionIdentifier, handler: IExtensionContributedURLHandler): void;76unregisterExtensionHandler(extensionId: ExtensionIdentifier): void;77}7879export interface IExtensionUrlHandlerOverride {80canHandleURL(uri: URI): boolean;81handleURL(uri: URI): Promise<boolean>;82}8384export class ExtensionUrlHandlerOverrideRegistry {8586private static readonly handlers = new Set<IExtensionUrlHandlerOverride>();8788static registerHandler(handler: IExtensionUrlHandlerOverride): IDisposable {89this.handlers.add(handler);9091return toDisposable(() => this.handlers.delete(handler));92}9394static getHandler(uri: URI): IExtensionUrlHandlerOverride | undefined {95for (const handler of this.handlers) {96if (handler.canHandleURL(uri)) {97return handler;98}99}100101return undefined;102}103}104105/**106* This class handles URLs which are directed towards extensions.107* If a URL is directed towards an inactive extension, it buffers it,108* activates the extension and re-opens the URL once the extension registers109* a URL handler. If the extension never registers a URL handler, the urls110* will eventually be garbage collected.111*112* It also makes sure the user confirms opening URLs directed towards extensions.113*/114class ExtensionUrlHandler implements IExtensionUrlHandler, IURLHandler {115116readonly _serviceBrand: undefined;117118private extensionHandlers = new Map<string, IExtensionContributedURLHandler>();119private uriBuffer = new Map<string, { timestamp: number; uri: URI }[]>();120private userTrustedExtensionsStorage: UserTrustedExtensionIdStorage;121private disposable: IDisposable;122123constructor(124@IURLService urlService: IURLService,125@IExtensionService private readonly extensionService: IExtensionService,126@IDialogService private readonly dialogService: IDialogService,127@ICommandService private readonly commandService: ICommandService,128@IHostService private readonly hostService: IHostService,129@IStorageService private readonly storageService: IStorageService,130@IConfigurationService private readonly configurationService: IConfigurationService,131@INotificationService private readonly notificationService: INotificationService,132@IProductService private readonly productService: IProductService,133) {134this.userTrustedExtensionsStorage = new UserTrustedExtensionIdStorage(storageService);135136const interval = disposableWindowInterval(mainWindow, () => this.garbageCollect(), THIRTY_SECONDS);137const urlToHandleValue = this.storageService.get(URL_TO_HANDLE, StorageScope.WORKSPACE);138if (urlToHandleValue) {139this.storageService.remove(URL_TO_HANDLE, StorageScope.WORKSPACE);140this.handleURL(URI.revive(JSON.parse(urlToHandleValue)), { trusted: true });141}142143this.disposable = combinedDisposable(144urlService.registerHandler(this),145interval146);147148const cache = ExtensionUrlBootstrapHandler.cache;149setTimeout(() => cache.forEach(([uri, option]) => this.handleURL(uri, option)));150}151152async handleURL(uri: URI, options?: IOpenURLOptions): Promise<boolean> {153if (!isExtensionId(uri.authority)) {154return false;155}156157const overrideHandler = ExtensionUrlHandlerOverrideRegistry.getHandler(uri);158if (overrideHandler) {159const handled = await overrideHandler.handleURL(uri);160if (handled) {161return handled;162}163}164165const extensionId = uri.authority;166167const initialHandler = this.extensionHandlers.get(ExtensionIdentifier.toKey(extensionId));168let extensionDisplayName: string;169170if (!initialHandler) {171// The extension is not yet activated, so let's check if it is installed and enabled172const extension = await this.extensionService.getExtension(extensionId);173if (!extension) {174await this.handleUnhandledURL(uri, extensionId, options);175return true;176} else {177extensionDisplayName = extension.displayName ?? '';178}179} else {180extensionDisplayName = initialHandler.extensionDisplayName;181}182183const trusted = options?.trusted184|| this.productService.trustedExtensionProtocolHandlers?.some(value => equalsIgnoreCase(value, extensionId))185|| this.didUserTrustExtension(ExtensionIdentifier.toKey(extensionId));186187if (!trusted) {188const uriString = uri.toString(false);189let uriLabel = uriString;190191if (uriLabel.length > 40) {192uriLabel = `${uriLabel.substring(0, 30)}...${uriLabel.substring(uriLabel.length - 5)}`;193}194195const result = await this.dialogService.confirm({196message: localize('confirmUrl', "Allow '{0}' extension to open this URI?", extensionDisplayName),197checkbox: {198label: localize('rememberConfirmUrl', "Do not ask me again for this extension"),199},200primaryButton: localize({ key: 'open', comment: ['&& denotes a mnemonic'] }, "&&Open"),201custom: {202markdownDetails: [{203markdown: new MarkdownString(`<div title="${uriString}" aria-label='${uriString}'>${uriLabel}</div>`, { supportHtml: true }),204}]205}206});207208if (!result.confirmed) {209return true;210}211212if (result.checkboxChecked) {213this.userTrustedExtensionsStorage.add(ExtensionIdentifier.toKey(extensionId));214}215}216217const handler = this.extensionHandlers.get(ExtensionIdentifier.toKey(extensionId));218219if (handler) {220if (!initialHandler) {221// forward it directly222return await this.handleURLByExtension(extensionId, handler, uri, options);223}224225// let the ExtensionUrlHandler instance handle this226return false;227}228229// collect URI for eventual extension activation230const timestamp = new Date().getTime();231let uris = this.uriBuffer.get(ExtensionIdentifier.toKey(extensionId));232233if (!uris) {234uris = [];235this.uriBuffer.set(ExtensionIdentifier.toKey(extensionId), uris);236}237238uris.push({ timestamp, uri });239240// activate the extension using ActivationKind.Immediate because URI handling might be part241// of resolving authorities (via authentication extensions)242await this.extensionService.activateByEvent(`onUri:${ExtensionIdentifier.toKey(extensionId)}`, ActivationKind.Immediate);243return true;244}245246registerExtensionHandler(extensionId: ExtensionIdentifier, handler: IExtensionContributedURLHandler): void {247this.extensionHandlers.set(ExtensionIdentifier.toKey(extensionId), handler);248249const uris = this.uriBuffer.get(ExtensionIdentifier.toKey(extensionId)) || [];250251for (const { uri } of uris) {252this.handleURLByExtension(extensionId, handler, uri);253}254255this.uriBuffer.delete(ExtensionIdentifier.toKey(extensionId));256}257258unregisterExtensionHandler(extensionId: ExtensionIdentifier): void {259this.extensionHandlers.delete(ExtensionIdentifier.toKey(extensionId));260}261262private async handleURLByExtension(extensionId: ExtensionIdentifier | string, handler: IURLHandler, uri: URI, options?: IOpenURLOptions): Promise<boolean> {263return await handler.handleURL(uri, options);264}265266private async handleUnhandledURL(uri: URI, extensionId: string, options?: IOpenURLOptions): Promise<void> {267try {268await this.commandService.executeCommand('workbench.extensions.installExtension', extensionId, {269justification: {270reason: `${localize('installDetail', "This extension wants to open a URI:")}\n${uri.toString()}`,271action: localize('openUri', "Open URI")272},273enable: true274});275} catch (error) {276if (!isCancellationError(error)) {277this.notificationService.error(error);278}279return;280}281282const extension = await this.extensionService.getExtension(extensionId);283284if (extension) {285await this.handleURL(uri, { ...options, trusted: true });286}287288/* Extension cannot be added and require window reload */289else {290const result = await this.dialogService.confirm({291message: localize('reloadAndHandle', "Extension '{0}' is not loaded. Would you like to reload the window to load the extension and open the URL?", extensionId),292primaryButton: localize({ key: 'reloadAndOpen', comment: ['&& denotes a mnemonic'] }, "&&Reload Window and Open")293});294295if (!result.confirmed) {296return;297}298299this.storageService.store(URL_TO_HANDLE, JSON.stringify(uri.toJSON()), StorageScope.WORKSPACE, StorageTarget.MACHINE);300await this.hostService.reload();301}302}303304// forget about all uris buffered more than 5 minutes ago305private garbageCollect(): void {306const now = new Date().getTime();307const uriBuffer = new Map<string, { timestamp: number; uri: URI }[]>();308309this.uriBuffer.forEach((uris, extensionId) => {310uris = uris.filter(({ timestamp }) => now - timestamp < FIVE_MINUTES);311312if (uris.length > 0) {313uriBuffer.set(extensionId, uris);314}315});316317this.uriBuffer = uriBuffer;318}319320private didUserTrustExtension(id: string): boolean {321if (this.userTrustedExtensionsStorage.has(id)) {322return true;323}324325return this.getConfirmedTrustedExtensionIdsFromConfiguration().indexOf(id) > -1;326}327328private getConfirmedTrustedExtensionIdsFromConfiguration(): Array<string> {329const trustedExtensionIds = this.configurationService.getValue(USER_TRUSTED_EXTENSIONS_CONFIGURATION_KEY);330331if (!Array.isArray(trustedExtensionIds)) {332return [];333}334335return trustedExtensionIds;336}337338dispose(): void {339this.disposable.dispose();340this.extensionHandlers.clear();341this.uriBuffer.clear();342}343}344345registerSingleton(IExtensionUrlHandler, ExtensionUrlHandler, InstantiationType.Eager);346347/**348* This class handles URLs before `ExtensionUrlHandler` is instantiated.349* More info: https://github.com/microsoft/vscode/issues/73101350*/351class ExtensionUrlBootstrapHandler implements IWorkbenchContribution, IURLHandler {352353static readonly ID = 'workbench.contrib.extensionUrlBootstrapHandler';354355private static _cache: [URI, IOpenURLOptions | undefined][] = [];356private static disposable: IDisposable;357358static get cache(): [URI, IOpenURLOptions | undefined][] {359ExtensionUrlBootstrapHandler.disposable.dispose();360361const result = ExtensionUrlBootstrapHandler._cache;362ExtensionUrlBootstrapHandler._cache = [];363return result;364}365366constructor(@IURLService urlService: IURLService) {367ExtensionUrlBootstrapHandler.disposable = urlService.registerHandler(this);368}369370async handleURL(uri: URI, options?: IOpenURLOptions): Promise<boolean> {371if (!isExtensionId(uri.authority)) {372return false;373}374375ExtensionUrlBootstrapHandler._cache.push([uri, options]);376return true;377}378}379380registerWorkbenchContribution2(ExtensionUrlBootstrapHandler.ID, ExtensionUrlBootstrapHandler, WorkbenchPhase.BlockRestore /* registration only */);381382class ManageAuthorizedExtensionURIsAction extends Action2 {383384constructor() {385super({386id: 'workbench.extensions.action.manageAuthorizedExtensionURIs',387title: localize2('manage', 'Manage Authorized Extension URIs...'),388category: localize2('extensions', 'Extensions'),389menu: {390id: MenuId.CommandPalette,391when: IsWebContext.toNegated()392}393});394}395396async run(accessor: ServicesAccessor): Promise<void> {397const storageService = accessor.get(IStorageService);398const quickInputService = accessor.get(IQuickInputService);399const storage = new UserTrustedExtensionIdStorage(storageService);400const items = storage.extensions.map((label): IQuickPickItem => ({ label, picked: true }));401402if (items.length === 0) {403await quickInputService.pick([{ label: localize('no', 'There are currently no authorized extension URIs.') }]);404return;405}406407const result = await quickInputService.pick(items, { canPickMany: true });408409if (!result) {410return;411}412413storage.set(result.map(item => item.label));414}415}416417registerAction2(ManageAuthorizedExtensionURIsAction);418419420