Path: blob/main/src/vs/platform/extensionManagement/common/extensionsProfileScannerService.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 { Queue } from '../../../base/common/async.js';6import { VSBuffer } from '../../../base/common/buffer.js';7import { Disposable } from '../../../base/common/lifecycle.js';8import { Emitter, Event } from '../../../base/common/event.js';9import { ResourceMap } from '../../../base/common/map.js';10import { URI, UriComponents } from '../../../base/common/uri.js';11import { Metadata, isIExtensionIdentifier } from './extensionManagement.js';12import { areSameExtensions } from './extensionManagementUtil.js';13import { IExtension, IExtensionIdentifier } from '../../extensions/common/extensions.js';14import { FileOperationResult, IFileService, toFileOperationResult } from '../../files/common/files.js';15import { createDecorator } from '../../instantiation/common/instantiation.js';16import { ILogService } from '../../log/common/log.js';17import { IUserDataProfilesService } from '../../userDataProfile/common/userDataProfile.js';18import { IUriIdentityService } from '../../uriIdentity/common/uriIdentity.js';19import { Mutable, isObject, isString, isUndefined } from '../../../base/common/types.js';20import { getErrorMessage } from '../../../base/common/errors.js';2122interface IStoredProfileExtension {23identifier: IExtensionIdentifier;24location: UriComponents | string;25relativeLocation: string | undefined;26version: string;27metadata?: Metadata;28}2930export const enum ExtensionsProfileScanningErrorCode {3132/**33* Error when trying to scan extensions from a profile that does not exist.34*/35ERROR_PROFILE_NOT_FOUND = 'ERROR_PROFILE_NOT_FOUND',3637/**38* Error when profile file is invalid.39*/40ERROR_INVALID_CONTENT = 'ERROR_INVALID_CONTENT',4142}4344export class ExtensionsProfileScanningError extends Error {45constructor(message: string, public code: ExtensionsProfileScanningErrorCode) {46super(message);47}48}4950export interface IScannedProfileExtension {51readonly identifier: IExtensionIdentifier;52readonly version: string;53readonly location: URI;54readonly metadata?: Metadata;55}5657export interface ProfileExtensionsEvent {58readonly extensions: readonly IScannedProfileExtension[];59readonly profileLocation: URI;60}6162export interface DidAddProfileExtensionsEvent extends ProfileExtensionsEvent {63readonly error?: Error;64}6566export interface DidRemoveProfileExtensionsEvent extends ProfileExtensionsEvent {67readonly error?: Error;68}6970export interface IProfileExtensionsScanOptions {71readonly bailOutWhenFileNotFound?: boolean;72}7374export const IExtensionsProfileScannerService = createDecorator<IExtensionsProfileScannerService>('IExtensionsProfileScannerService');75export interface IExtensionsProfileScannerService {76readonly _serviceBrand: undefined;7778readonly onAddExtensions: Event<ProfileExtensionsEvent>;79readonly onDidAddExtensions: Event<DidAddProfileExtensionsEvent>;80readonly onRemoveExtensions: Event<ProfileExtensionsEvent>;81readonly onDidRemoveExtensions: Event<DidRemoveProfileExtensionsEvent>;8283scanProfileExtensions(profileLocation: URI, options?: IProfileExtensionsScanOptions): Promise<IScannedProfileExtension[]>;84addExtensionsToProfile(extensions: [IExtension, Metadata | undefined][], profileLocation: URI, keepExistingVersions?: boolean): Promise<IScannedProfileExtension[]>;85updateMetadata(extensions: [IExtension, Metadata | undefined][], profileLocation: URI): Promise<IScannedProfileExtension[]>;86removeExtensionsFromProfile(extensions: IExtensionIdentifier[], profileLocation: URI): Promise<void>;87}8889export abstract class AbstractExtensionsProfileScannerService extends Disposable implements IExtensionsProfileScannerService {90readonly _serviceBrand: undefined;9192private readonly _onAddExtensions = this._register(new Emitter<ProfileExtensionsEvent>());93readonly onAddExtensions = this._onAddExtensions.event;9495private readonly _onDidAddExtensions = this._register(new Emitter<DidAddProfileExtensionsEvent>());96readonly onDidAddExtensions = this._onDidAddExtensions.event;9798private readonly _onRemoveExtensions = this._register(new Emitter<ProfileExtensionsEvent>());99readonly onRemoveExtensions = this._onRemoveExtensions.event;100101private readonly _onDidRemoveExtensions = this._register(new Emitter<DidRemoveProfileExtensionsEvent>());102readonly onDidRemoveExtensions = this._onDidRemoveExtensions.event;103104private readonly resourcesAccessQueueMap = new ResourceMap<Queue<IScannedProfileExtension[]>>();105106constructor(107private readonly extensionsLocation: URI,108@IFileService private readonly fileService: IFileService,109@IUserDataProfilesService private readonly userDataProfilesService: IUserDataProfilesService,110@IUriIdentityService private readonly uriIdentityService: IUriIdentityService,111@ILogService private readonly logService: ILogService,112) {113super();114}115116scanProfileExtensions(profileLocation: URI, options?: IProfileExtensionsScanOptions): Promise<IScannedProfileExtension[]> {117return this.withProfileExtensions(profileLocation, undefined, options);118}119120async addExtensionsToProfile(extensions: [IExtension, Metadata | undefined][], profileLocation: URI, keepExistingVersions?: boolean): Promise<IScannedProfileExtension[]> {121const extensionsToRemove: IScannedProfileExtension[] = [];122const extensionsToAdd: IScannedProfileExtension[] = [];123try {124await this.withProfileExtensions(profileLocation, existingExtensions => {125const result: IScannedProfileExtension[] = [];126if (keepExistingVersions) {127result.push(...existingExtensions);128} else {129for (const existing of existingExtensions) {130if (extensions.some(([e]) => areSameExtensions(e.identifier, existing.identifier) && e.manifest.version !== existing.version)) {131// Remove the existing extension with different version132extensionsToRemove.push(existing);133} else {134result.push(existing);135}136}137}138for (const [extension, metadata] of extensions) {139const index = result.findIndex(e => areSameExtensions(e.identifier, extension.identifier) && e.version === extension.manifest.version);140const extensionToAdd = { identifier: extension.identifier, version: extension.manifest.version, location: extension.location, metadata };141if (index === -1) {142extensionsToAdd.push(extensionToAdd);143result.push(extensionToAdd);144} else {145result.splice(index, 1, extensionToAdd);146}147}148if (extensionsToAdd.length) {149this._onAddExtensions.fire({ extensions: extensionsToAdd, profileLocation });150}151if (extensionsToRemove.length) {152this._onRemoveExtensions.fire({ extensions: extensionsToRemove, profileLocation });153}154return result;155});156if (extensionsToAdd.length) {157this._onDidAddExtensions.fire({ extensions: extensionsToAdd, profileLocation });158}159if (extensionsToRemove.length) {160this._onDidRemoveExtensions.fire({ extensions: extensionsToRemove, profileLocation });161}162return extensionsToAdd;163} catch (error) {164if (extensionsToAdd.length) {165this._onDidAddExtensions.fire({ extensions: extensionsToAdd, error, profileLocation });166}167if (extensionsToRemove.length) {168this._onDidRemoveExtensions.fire({ extensions: extensionsToRemove, error, profileLocation });169}170throw error;171}172}173174async updateMetadata(extensions: [IExtension, Metadata][], profileLocation: URI): Promise<IScannedProfileExtension[]> {175const updatedExtensions: IScannedProfileExtension[] = [];176await this.withProfileExtensions(profileLocation, profileExtensions => {177const result: IScannedProfileExtension[] = [];178for (const profileExtension of profileExtensions) {179const extension = extensions.find(([e]) => areSameExtensions({ id: e.identifier.id }, { id: profileExtension.identifier.id }) && e.manifest.version === profileExtension.version);180if (extension) {181profileExtension.metadata = { ...profileExtension.metadata, ...extension[1] };182updatedExtensions.push(profileExtension);183result.push(profileExtension);184} else {185result.push(profileExtension);186}187}188return result;189});190return updatedExtensions;191}192193async removeExtensionsFromProfile(extensions: IExtensionIdentifier[], profileLocation: URI): Promise<void> {194const extensionsToRemove: IScannedProfileExtension[] = [];195try {196await this.withProfileExtensions(profileLocation, profileExtensions => {197const result: IScannedProfileExtension[] = [];198for (const e of profileExtensions) {199if (extensions.some(extension => areSameExtensions(e.identifier, extension))) {200extensionsToRemove.push(e);201} else {202result.push(e);203}204}205if (extensionsToRemove.length) {206this._onRemoveExtensions.fire({ extensions: extensionsToRemove, profileLocation });207}208return result;209});210if (extensionsToRemove.length) {211this._onDidRemoveExtensions.fire({ extensions: extensionsToRemove, profileLocation });212}213} catch (error) {214if (extensionsToRemove.length) {215this._onDidRemoveExtensions.fire({ extensions: extensionsToRemove, error, profileLocation });216}217throw error;218}219}220221private async withProfileExtensions(file: URI, updateFn?: (extensions: Mutable<IScannedProfileExtension>[]) => IScannedProfileExtension[], options?: IProfileExtensionsScanOptions): Promise<IScannedProfileExtension[]> {222return this.getResourceAccessQueue(file).queue(async () => {223let extensions: IScannedProfileExtension[] = [];224225// Read226let storedProfileExtensions: IStoredProfileExtension[] | undefined;227try {228const content = await this.fileService.readFile(file);229storedProfileExtensions = JSON.parse(content.value.toString().trim() || '[]');230} catch (error) {231if (toFileOperationResult(error) !== FileOperationResult.FILE_NOT_FOUND) {232throw error;233}234// migrate from old location, remove this after couple of releases235if (this.uriIdentityService.extUri.isEqual(file, this.userDataProfilesService.defaultProfile.extensionsResource)) {236storedProfileExtensions = await this.migrateFromOldDefaultProfileExtensionsLocation();237}238if (!storedProfileExtensions && options?.bailOutWhenFileNotFound) {239throw new ExtensionsProfileScanningError(getErrorMessage(error), ExtensionsProfileScanningErrorCode.ERROR_PROFILE_NOT_FOUND);240}241}242if (storedProfileExtensions) {243if (!Array.isArray(storedProfileExtensions)) {244this.throwInvalidConentError(file);245}246// TODO @sandy081: Remove this migration after couple of releases247let migrate = false;248for (const e of storedProfileExtensions) {249if (!isStoredProfileExtension(e)) {250this.throwInvalidConentError(file);251}252let location: URI;253if (isString(e.relativeLocation) && e.relativeLocation) {254// Extension in new format. No migration needed.255location = this.resolveExtensionLocation(e.relativeLocation);256} else if (isString(e.location)) {257this.logService.warn(`Extensions profile: Ignoring extension with invalid location: ${e.location}`);258continue;259} else {260location = URI.revive(e.location);261const relativePath = this.toRelativePath(location);262if (relativePath) {263// Extension in old format. Migrate to new format.264migrate = true;265e.relativeLocation = relativePath;266}267}268if (isUndefined(e.metadata?.hasPreReleaseVersion) && e.metadata?.preRelease) {269migrate = true;270e.metadata.hasPreReleaseVersion = true;271}272const uuid = e.metadata?.id ?? e.identifier.uuid;273extensions.push({274identifier: uuid ? { id: e.identifier.id, uuid } : { id: e.identifier.id },275location,276version: e.version,277metadata: e.metadata,278});279}280if (migrate) {281await this.fileService.writeFile(file, VSBuffer.fromString(JSON.stringify(storedProfileExtensions)));282}283}284285// Update286if (updateFn) {287extensions = updateFn(extensions);288const storedProfileExtensions: IStoredProfileExtension[] = extensions.map(e => ({289identifier: e.identifier,290version: e.version,291// retain old format so that old clients can read it292location: e.location.toJSON(),293relativeLocation: this.toRelativePath(e.location),294metadata: e.metadata295}));296await this.fileService.writeFile(file, VSBuffer.fromString(JSON.stringify(storedProfileExtensions)));297}298299return extensions;300});301}302303private throwInvalidConentError(file: URI): void {304throw new ExtensionsProfileScanningError(`Invalid extensions content in ${file.toString()}`, ExtensionsProfileScanningErrorCode.ERROR_INVALID_CONTENT);305}306307private toRelativePath(extensionLocation: URI): string | undefined {308return this.uriIdentityService.extUri.isEqual(this.uriIdentityService.extUri.dirname(extensionLocation), this.extensionsLocation)309? this.uriIdentityService.extUri.basename(extensionLocation)310: undefined;311}312313private resolveExtensionLocation(path: string): URI {314return this.uriIdentityService.extUri.joinPath(this.extensionsLocation, path);315}316317private _migrationPromise: Promise<IStoredProfileExtension[] | undefined> | undefined;318private async migrateFromOldDefaultProfileExtensionsLocation(): Promise<IStoredProfileExtension[] | undefined> {319if (!this._migrationPromise) {320this._migrationPromise = (async () => {321const oldDefaultProfileExtensionsLocation = this.uriIdentityService.extUri.joinPath(this.userDataProfilesService.defaultProfile.location, 'extensions.json');322const oldDefaultProfileExtensionsInitLocation = this.uriIdentityService.extUri.joinPath(this.extensionsLocation, '.init-default-profile-extensions');323let content: string;324try {325content = (await this.fileService.readFile(oldDefaultProfileExtensionsLocation)).value.toString();326} catch (error) {327if (toFileOperationResult(error) === FileOperationResult.FILE_NOT_FOUND) {328return undefined;329}330throw error;331}332333this.logService.info('Migrating extensions from old default profile location', oldDefaultProfileExtensionsLocation.toString());334let storedProfileExtensions: IStoredProfileExtension[] | undefined;335try {336const parsedData = JSON.parse(content);337if (Array.isArray(parsedData) && parsedData.every(candidate => isStoredProfileExtension(candidate))) {338storedProfileExtensions = parsedData;339} else {340this.logService.warn('Skipping migrating from old default profile locaiton: Found invalid data', parsedData);341}342} catch (error) {343/* Ignore */344this.logService.error(error);345}346347if (storedProfileExtensions) {348try {349await this.fileService.createFile(this.userDataProfilesService.defaultProfile.extensionsResource, VSBuffer.fromString(JSON.stringify(storedProfileExtensions)), { overwrite: false });350this.logService.info('Migrated extensions from old default profile location to new location', oldDefaultProfileExtensionsLocation.toString(), this.userDataProfilesService.defaultProfile.extensionsResource.toString());351} catch (error) {352if (toFileOperationResult(error) === FileOperationResult.FILE_MODIFIED_SINCE) {353this.logService.info('Migration from old default profile location to new location is done by another window', oldDefaultProfileExtensionsLocation.toString(), this.userDataProfilesService.defaultProfile.extensionsResource.toString());354} else {355throw error;356}357}358}359360try {361await this.fileService.del(oldDefaultProfileExtensionsLocation);362} catch (error) {363if (toFileOperationResult(error) !== FileOperationResult.FILE_NOT_FOUND) {364this.logService.error(error);365}366}367368try {369await this.fileService.del(oldDefaultProfileExtensionsInitLocation);370} catch (error) {371if (toFileOperationResult(error) !== FileOperationResult.FILE_NOT_FOUND) {372this.logService.error(error);373}374}375376return storedProfileExtensions;377})();378}379return this._migrationPromise;380}381382private getResourceAccessQueue(file: URI): Queue<IScannedProfileExtension[]> {383let resourceQueue = this.resourcesAccessQueueMap.get(file);384if (!resourceQueue) {385resourceQueue = new Queue<IScannedProfileExtension[]>();386this.resourcesAccessQueueMap.set(file, resourceQueue);387}388return resourceQueue;389}390}391392function isStoredProfileExtension(candidate: any): candidate is IStoredProfileExtension {393return isObject(candidate)394&& isIExtensionIdentifier(candidate.identifier)395&& (isUriComponents(candidate.location) || (isString(candidate.location) && candidate.location))396&& (isUndefined(candidate.relativeLocation) || isString(candidate.relativeLocation))397&& candidate.version && isString(candidate.version);398}399400function isUriComponents(thing: unknown): thing is UriComponents {401if (!thing) {402return false;403}404return isString((<any>thing).path) &&405isString((<any>thing).scheme);406}407408409