diff --git a/src/vs/platform/extensionManagement/common/abstractExtensionManagementService.ts b/src/vs/platform/extensionManagement/common/abstractExtensionManagementService.ts index f7834854d97..284658e08d6 100644 --- a/src/vs/platform/extensionManagement/common/abstractExtensionManagementService.ts +++ b/src/vs/platform/extensionManagement/common/abstractExtensionManagementService.ts @@ -23,6 +23,7 @@ import { } from 'vs/platform/extensionManagement/common/extensionManagement'; import { areSameExtensions, ExtensionKey, getGalleryExtensionId, getGalleryExtensionTelemetryData, getLocalExtensionTelemetryData } from 'vs/platform/extensionManagement/common/extensionManagementUtil'; import { ExtensionType, IExtensionManifest, isApplicationScopedExtension, TargetPlatform } from 'vs/platform/extensions/common/extensions'; +import { areApiProposalsCompatible } from 'vs/platform/extensions/common/extensionValidator'; import { ILogService } from 'vs/platform/log/common/log'; import { IProductService } from 'vs/platform/product/common/productService'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; @@ -548,6 +549,10 @@ export abstract class AbstractExtensionManagementService extends Disposable impl compatibleExtension = await this.getCompatibleVersion(extension, sameVersion, installPreRelease, productVersion); if (!compatibleExtension) { + const incompatibleApiProposalsMessages: string[] = []; + if (!areApiProposalsCompatible(extension.properties.enabledApiProposals ?? [], incompatibleApiProposalsMessages)) { + throw new ExtensionManagementError(nls.localize('incompatibleAPI', "Can't install '{0}' extension. {1}", extension.displayName ?? extension.identifier.id, incompatibleApiProposalsMessages[0]), ExtensionManagementErrorCode.IncompatibleApi); + } /** If no compatible release version is found, check if the extension has a release version or not and throw relevant error */ if (!installPreRelease && extension.properties.isPreReleaseVersion && (await this.galleryService.getExtensions([extension.identifier], CancellationToken.None))[0]) { throw new ExtensionManagementError(nls.localize('notFoundReleaseExtension', "Can't install release version of '{0}' extension because it has no release version.", extension.displayName ?? extension.identifier.id), ExtensionManagementErrorCode.ReleaseVersionNotFound); diff --git a/src/vs/platform/extensionManagement/common/extensionGalleryService.ts b/src/vs/platform/extensionManagement/common/extensionGalleryService.ts index 5548b8eefb9..955d3bd3a20 100644 --- a/src/vs/platform/extensionManagement/common/extensionGalleryService.ts +++ b/src/vs/platform/extensionManagement/common/extensionGalleryService.ts @@ -18,7 +18,7 @@ import { IEnvironmentService } from 'vs/platform/environment/common/environment' import { getTargetPlatform, IExtensionGalleryService, IExtensionIdentifier, IExtensionInfo, IGalleryExtension, IGalleryExtensionAsset, IGalleryExtensionAssets, IGalleryExtensionVersion, InstallOperation, IQueryOptions, IExtensionsControlManifest, isNotWebExtensionInWebTargetPlatform, isTargetPlatformCompatible, ITranslation, SortBy, SortOrder, StatisticType, toTargetPlatform, WEB_EXTENSION_TAG, IExtensionQueryOptions, IDeprecationInfo, ISearchPrefferedResults, ExtensionGalleryError, ExtensionGalleryErrorCode, IProductVersion } from 'vs/platform/extensionManagement/common/extensionManagement'; import { adoptToGalleryExtensionId, areSameExtensions, getGalleryExtensionId, getGalleryExtensionTelemetryData } from 'vs/platform/extensionManagement/common/extensionManagementUtil'; import { IExtensionManifest, TargetPlatform } from 'vs/platform/extensions/common/extensions'; -import { isEngineValid } from 'vs/platform/extensions/common/extensionValidator'; +import { areApiProposalsCompatible, isEngineValid } from 'vs/platform/extensions/common/extensionValidator'; import { IFileService } from 'vs/platform/files/common/files'; import { ILogService } from 'vs/platform/log/common/log'; import { IProductService } from 'vs/platform/product/common/productService'; @@ -209,6 +209,7 @@ const PropertyType = { ExtensionPack: 'Microsoft.VisualStudio.Code.ExtensionPack', Engine: 'Microsoft.VisualStudio.Code.Engine', PreRelease: 'Microsoft.VisualStudio.Code.PreRelease', + EnabledApiProposals: 'Microsoft.VisualStudio.Code.EnabledApiProposals', LocalizedLanguages: 'Microsoft.VisualStudio.Code.LocalizedLanguages', WebExtension: 'Microsoft.VisualStudio.Code.WebExtension', SponsorLink: 'Microsoft.VisualStudio.Code.SponsorLink', @@ -430,6 +431,12 @@ function isPreReleaseVersion(version: IRawGalleryExtensionVersion): boolean { return values.length > 0 && values[0].value === 'true'; } +function getEnabledApiProposals(version: IRawGalleryExtensionVersion): string[] { + const values = version.properties ? version.properties.filter(p => p.key === PropertyType.EnabledApiProposals) : []; + const value = (values.length > 0 && values[0].value) || ''; + return value ? value.split(',') : []; +} + function getLocalizedLanguages(version: IRawGalleryExtensionVersion): string[] { const values = version.properties ? version.properties.filter(p => p.key === PropertyType.LocalizedLanguages) : []; const value = (values.length > 0 && values[0].value) || ''; @@ -548,6 +555,7 @@ function toExtension(galleryExtension: IRawGalleryExtension, version: IRawGaller dependencies: getExtensions(version, PropertyType.Dependency), extensionPack: getExtensions(version, PropertyType.ExtensionPack), engine: getEngine(version), + enabledApiProposals: getEnabledApiProposals(version), localizedLanguages: getLocalizedLanguages(version), targetPlatform: getTargetPlatformForExtensionVersion(version), isPreReleaseVersion: isPreReleaseVersion(version) @@ -704,7 +712,16 @@ abstract class AbstractExtensionGalleryService implements IExtensionGalleryServi } engine = manifest.engines.vscode; } - return isEngineValid(engine, productVersion.version, productVersion.date); + + if (!isEngineValid(engine, productVersion.version, productVersion.date)) { + return false; + } + + if (!areApiProposalsCompatible(extension.properties.enabledApiProposals ?? [])) { + return false; + } + + return true; } private async isValidVersion(extension: string, rawGalleryExtensionVersion: IRawGalleryExtensionVersion, versionType: 'release' | 'prerelease' | 'any', compatible: boolean, allTargetPlatforms: TargetPlatform[], targetPlatform: TargetPlatform, productVersion: IProductVersion = { version: this.productService.version, date: this.productService.date }): Promise { @@ -915,7 +932,18 @@ abstract class AbstractExtensionGalleryService implements IExtensionGalleryServi continue; } // Allow any version if includePreRelease flag is set otherwise only release versions are allowed - if (await this.isValidVersion(getGalleryExtensionId(rawGalleryExtension.publisher.publisherName, rawGalleryExtension.extensionName), rawGalleryExtensionVersion, includePreRelease ? 'any' : 'release', criteria.compatible, allTargetPlatforms, criteria.targetPlatform, criteria.productVersion)) { + if (await this.isValidVersion( + getGalleryExtensionId(rawGalleryExtension.publisher.publisherName, rawGalleryExtension.extensionName), + rawGalleryExtensionVersion, + includePreRelease ? 'any' : 'release', + criteria.compatible, + allTargetPlatforms, + criteria.targetPlatform, + criteria.productVersion) + ) { + if (criteria.compatible && !areApiProposalsCompatible(getEnabledApiProposals(rawGalleryExtensionVersion))) { + return null; + } return toExtension(rawGalleryExtension, rawGalleryExtensionVersion, allTargetPlatforms, queryContext); } if (version && rawGalleryExtensionVersion.version === version) { @@ -1161,7 +1189,15 @@ abstract class AbstractExtensionGalleryService implements IExtensionGalleryServi const validVersions: IRawGalleryExtensionVersion[] = []; await Promise.all(galleryExtensions[0].versions.map(async (version) => { try { - if (await this.isValidVersion(extension.identifier.id, version, includePreRelease ? 'any' : 'release', true, allTargetPlatforms, targetPlatform)) { + if ( + (await this.isValidVersion( + extension.identifier.id, + version, includePreRelease ? 'any' : 'release', + true, + allTargetPlatforms, + targetPlatform)) + && areApiProposalsCompatible(getEnabledApiProposals(version)) + ) { validVersions.push(version); } } catch (error) { /* Ignore error and skip version */ } diff --git a/src/vs/platform/extensionManagement/common/extensionManagement.ts b/src/vs/platform/extensionManagement/common/extensionManagement.ts index 0f608249f78..61c9716cc56 100644 --- a/src/vs/platform/extensionManagement/common/extensionManagement.ts +++ b/src/vs/platform/extensionManagement/common/extensionManagement.ts @@ -159,6 +159,7 @@ export interface IGalleryExtensionProperties { dependencies?: string[]; extensionPack?: string[]; engine?: string; + enabledApiProposals?: string[]; localizedLanguages?: string[]; targetPlatform: TargetPlatform; isPreReleaseVersion: boolean; @@ -437,6 +438,7 @@ export const enum ExtensionManagementErrorCode { Deprecated = 'Deprecated', Malicious = 'Malicious', Incompatible = 'Incompatible', + IncompatibleApi = 'IncompatibleApi', IncompatibleTargetPlatform = 'IncompatibleTargetPlatform', ReleaseVersionNotFound = 'ReleaseVersionNotFound', Invalid = 'Invalid', diff --git a/src/vs/platform/extensions/common/extensionValidator.ts b/src/vs/platform/extensions/common/extensionValidator.ts index cee5eaeedef..93ac0554313 100644 --- a/src/vs/platform/extensions/common/extensionValidator.ts +++ b/src/vs/platform/extensions/common/extensionValidator.ts @@ -8,7 +8,8 @@ import Severity from 'vs/base/common/severity'; import { URI } from 'vs/base/common/uri'; import * as nls from 'vs/nls'; import * as semver from 'vs/base/common/semver/semver'; -import { IExtensionManifest } from 'vs/platform/extensions/common/extensions'; +import { IExtensionManifest, parseApiProposals } from 'vs/platform/extensions/common/extensions'; +import { allApiProposals } from 'vs/platform/extensions/common/extensionsApiProposals'; export interface IParsedVersion { hasCaret: boolean; @@ -314,12 +315,22 @@ export function validateExtensionManifest(productVersion: string, productDate: P } const notices: string[] = []; - const isValid = isValidExtensionVersion(productVersion, productDate, extensionManifest, extensionIsBuiltin, notices); - if (!isValid) { + const validExtensionVersion = isValidExtensionVersion(productVersion, productDate, extensionManifest, extensionIsBuiltin, notices); + if (!validExtensionVersion) { for (const notice of notices) { validations.push([Severity.Error, notice]); } } + + if (extensionManifest.enabledApiProposals?.length) { + const incompatibleNotices: string[] = []; + if (!areApiProposalsCompatible([...extensionManifest.enabledApiProposals], incompatibleNotices)) { + for (const notice of incompatibleNotices) { + validations.push([Severity.Error, notice]); + } + } + } + return validations; } @@ -338,6 +349,38 @@ export function isEngineValid(engine: string, version: string, date: ProductDate return engine === '*' || isVersionValid(version, date, engine); } +export function areApiProposalsCompatible(apiProposals: string[]): boolean; +export function areApiProposalsCompatible(apiProposals: string[], notices: string[]): boolean; +export function areApiProposalsCompatible(apiProposals: string[], productApiProposals: Readonly<{ [proposalName: string]: Readonly<{ proposal: string; version?: number }> }>): boolean; +export function areApiProposalsCompatible(apiProposals: string[], arg1?: any): boolean { + if (apiProposals.length === 0) { + return true; + } + const notices: string[] | undefined = Array.isArray(arg1) ? arg1 : undefined; + const productApiProposals: Readonly<{ [proposalName: string]: Readonly<{ proposal: string; version?: number }> }> = (notices ? undefined : arg1) ?? allApiProposals; + const incompatibleNotices: string[] = []; + const parsedProposals = parseApiProposals(apiProposals); + for (const { proposalName, version } of parsedProposals) { + const existingProposal = productApiProposals[proposalName]; + if (!existingProposal) { + continue; + } + if (!version) { + continue; + } + if (existingProposal.version !== version) { + if (existingProposal.version) { + incompatibleNotices.push(nls.localize('apiProposalMismatch', "Extension is not compatible with API proposal {0}. Extension requires version {1} but product has version {2}.", proposalName, version, existingProposal.version)); + } else { + incompatibleNotices.push(nls.localize('apiProposalMismatchNoVersion', "Extension is not compatible with API proposal {0}. Extension requires version {1} but product has no version defined.", proposalName, version)); + } + } + } + notices?.push(...incompatibleNotices); + return incompatibleNotices.length === 0; + +} + function isVersionValid(currentVersion: string, date: ProductDate, requestedVersion: string, notices: string[] = []): boolean { const desiredVersion = normalizeVersion(parseVersion(requestedVersion)); diff --git a/src/vs/platform/extensions/common/extensions.ts b/src/vs/platform/extensions/common/extensions.ts index 5ccdd4744fc..dc022ce0098 100644 --- a/src/vs/platform/extensions/common/extensions.ts +++ b/src/vs/platform/extensions/common/extensions.ts @@ -494,6 +494,13 @@ export function isResolverExtension(manifest: IExtensionManifest, remoteAuthorit return false; } +export function parseApiProposals(enabledApiProposals: string[]): { proposalName: string; version?: number }[] { + return enabledApiProposals.map(proposal => { + const [proposalName, version] = proposal.split('@'); + return { proposalName, version: version ? parseInt(version) : undefined }; + }); +} + export function parseEnabledApiProposalNames(enabledApiProposals: string[]): string[] { return enabledApiProposals.map(proposal => proposal.split('@')[0]); } diff --git a/src/vs/platform/extensions/test/common/extensionValidator.test.ts b/src/vs/platform/extensions/test/common/extensionValidator.test.ts index a9e95e6dcc7..6ac5821e08a 100644 --- a/src/vs/platform/extensions/test/common/extensionValidator.test.ts +++ b/src/vs/platform/extensions/test/common/extensionValidator.test.ts @@ -5,7 +5,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from 'vs/base/test/common/utils'; import { IExtensionManifest } from 'vs/platform/extensions/common/extensions'; -import { INormalizedVersion, IParsedVersion, isValidExtensionVersion, isValidVersion, isValidVersionStr, normalizeVersion, parseVersion } from 'vs/platform/extensions/common/extensionValidator'; +import { areApiProposalsCompatible, INormalizedVersion, IParsedVersion, isValidExtensionVersion, isValidVersion, isValidVersionStr, normalizeVersion, parseVersion } from 'vs/platform/extensions/common/extensionValidator'; suite('Extension Version Validator', () => { @@ -423,4 +423,21 @@ suite('Extension Version Validator', () => { }; assert.strictEqual(isValidExtensionVersion('1.44.0', undefined, manifest, false, []), false); }); + + test('areApiProposalsCompatible', () => { + assert.strictEqual(areApiProposalsCompatible([]), true); + assert.strictEqual(areApiProposalsCompatible([], ['hello']), true); + assert.strictEqual(areApiProposalsCompatible([], {}), true); + assert.strictEqual(areApiProposalsCompatible(['proposal1'], {}), true); + assert.strictEqual(areApiProposalsCompatible(['proposal1'], { 'proposal1': { proposal: '' } }), true); + assert.strictEqual(areApiProposalsCompatible(['proposal1'], { 'proposal1': { proposal: '', version: 1 } }), true); + assert.strictEqual(areApiProposalsCompatible(['proposal1@1'], { 'proposal1': { proposal: '', version: 1 } }), true); + assert.strictEqual(areApiProposalsCompatible(['proposal1'], { 'proposal2': { proposal: '' } }), true); + assert.strictEqual(areApiProposalsCompatible(['proposal1', 'proposal2'], {}), true); + assert.strictEqual(areApiProposalsCompatible(['proposal1', 'proposal2'], { 'proposal1': { proposal: '' } }), true); + + assert.strictEqual(areApiProposalsCompatible(['proposal1@1'], { 'proposal1': { proposal: '', version: 2 } }), false); + assert.strictEqual(areApiProposalsCompatible(['proposal1@1'], { 'proposal1': { proposal: '' } }), false); + }); + }); diff --git a/src/vs/platform/userDataSync/common/extensionsSync.ts b/src/vs/platform/userDataSync/common/extensionsSync.ts index 53561e249db..7af2df9134a 100644 --- a/src/vs/platform/userDataSync/common/extensionsSync.ts +++ b/src/vs/platform/userDataSync/common/extensionsSync.ts @@ -533,7 +533,7 @@ export class LocalExtensionsProvider { addToSkipped.push(e); this.logService.info(`${syncResourceLogLabel}: Skipped synchronizing extension`, gallery.displayName || gallery.identifier.id); } - if (error instanceof ExtensionManagementError && [ExtensionManagementErrorCode.Incompatible, ExtensionManagementErrorCode.IncompatibleTargetPlatform].includes(error.code)) { + if (error instanceof ExtensionManagementError && [ExtensionManagementErrorCode.Incompatible, ExtensionManagementErrorCode.IncompatibleApi, ExtensionManagementErrorCode.IncompatibleTargetPlatform].includes(error.code)) { this.logService.info(`${syncResourceLogLabel}: Skipped synchronizing extension because the compatible extension is not found.`, gallery.displayName || gallery.identifier.id); } else if (error) { this.logService.error(error); diff --git a/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts b/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts index fec28e85da5..975ebf2c9a6 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts @@ -134,7 +134,7 @@ export class PromptExtensionInstallFailureAction extends Action { return; } - if ([ExtensionManagementErrorCode.Incompatible, ExtensionManagementErrorCode.IncompatibleTargetPlatform, ExtensionManagementErrorCode.Malicious, ExtensionManagementErrorCode.Deprecated].includes(this.error.name)) { + if ([ExtensionManagementErrorCode.Incompatible, ExtensionManagementErrorCode.IncompatibleApi, ExtensionManagementErrorCode.IncompatibleTargetPlatform, ExtensionManagementErrorCode.Malicious, ExtensionManagementErrorCode.Deprecated].includes(this.error.name)) { await this.dialogService.info(getErrorMessage(this.error)); return; } diff --git a/src/vs/workbench/services/extensionManagement/electron-sandbox/remoteExtensionManagementService.ts b/src/vs/workbench/services/extensionManagement/electron-sandbox/remoteExtensionManagementService.ts index 79cf8b0ad4e..3ca587e7c8b 100644 --- a/src/vs/workbench/services/extensionManagement/electron-sandbox/remoteExtensionManagementService.ts +++ b/src/vs/workbench/services/extensionManagement/electron-sandbox/remoteExtensionManagementService.ts @@ -24,6 +24,7 @@ import { IUserDataProfilesService } from 'vs/platform/userDataProfile/common/use import { IUserDataProfileService } from 'vs/workbench/services/userDataProfile/common/userDataProfile'; import { IRemoteUserDataProfilesService } from 'vs/workbench/services/userDataProfile/common/remoteUserDataProfiles'; import { IUriIdentityService } from 'vs/platform/uriIdentity/common/uriIdentity'; +import { areApiProposalsCompatible } from 'vs/platform/extensions/common/extensionValidator'; export class NativeRemoteExtensionManagementService extends RemoteExtensionManagementService { @@ -134,6 +135,10 @@ export class NativeRemoteExtensionManagementService extends RemoteExtensionManag } if (!compatibleExtension) { + const incompatibleApiProposalsMessages: string[] = []; + if (!areApiProposalsCompatible(extension.properties.enabledApiProposals ?? [], incompatibleApiProposalsMessages)) { + throw new ExtensionManagementError(localize('incompatibleAPI', "Can't install '{0}' extension. {1}", extension.displayName ?? extension.identifier.id, incompatibleApiProposalsMessages[0]), ExtensionManagementErrorCode.IncompatibleApi); + } /** If no compatible release version is found, check if the extension has a release version or not and throw relevant error */ if (!includePreRelease && extension.properties.isPreReleaseVersion && (await this.galleryService.getExtensions([extension.identifier], CancellationToken.None))[0]) { throw new ExtensionManagementError(localize('notFoundReleaseExtension', "Can't install release version of '{0}' extension because it has no release version.", extension.identifier.id), ExtensionManagementErrorCode.ReleaseVersionNotFound); diff --git a/src/vs/workbench/services/extensions/common/extensionsProposedApi.ts b/src/vs/workbench/services/extensions/common/extensionsProposedApi.ts index cad7f99d45c..aad00bc62cc 100644 --- a/src/vs/workbench/services/extensions/common/extensionsProposedApi.ts +++ b/src/vs/workbench/services/extensions/common/extensionsProposedApi.ts @@ -4,11 +4,17 @@ *--------------------------------------------------------------------------------------------*/ import { isNonEmptyArray } from 'vs/base/common/arrays'; -import { ExtensionIdentifier, IExtensionDescription } from 'vs/platform/extensions/common/extensions'; +import { localize } from 'vs/nls'; +import { Disposable } from 'vs/base/common/lifecycle'; +import { ExtensionIdentifier, IExtensionDescription, IExtensionManifest } from 'vs/platform/extensions/common/extensions'; import { allApiProposals, ApiProposalName } from 'vs/platform/extensions/common/extensionsApiProposals'; +import { SyncDescriptor } from 'vs/platform/instantiation/common/descriptors'; import { ILogService } from 'vs/platform/log/common/log'; import { IProductService } from 'vs/platform/product/common/productService'; +import { Registry } from 'vs/platform/registry/common/platform'; import { IWorkbenchEnvironmentService } from 'vs/workbench/services/environment/common/environmentService'; +import { Extensions, IExtensionFeatureMarkdownRenderer, IExtensionFeaturesRegistry, IRenderedData } from 'vs/workbench/services/extensionManagement/common/extensionFeatures'; +import { IMarkdownString, MarkdownString } from 'vs/base/common/htmlContent'; export class ExtensionsProposedApi { @@ -107,3 +113,35 @@ export class ExtensionsProposedApi { } } } + +class ApiProposalsMarkdowneRenderer extends Disposable implements IExtensionFeatureMarkdownRenderer { + + readonly type = 'markdown'; + + shouldRender(manifest: IExtensionManifest): boolean { + return !!manifest.enabledApiProposals?.length; + } + + render(manifest: IExtensionManifest): IRenderedData { + const enabledApiProposals = manifest.enabledApiProposals || []; + const data = new MarkdownString(); + if (enabledApiProposals.length) { + for (const proposal of enabledApiProposals) { + data.appendMarkdown(`- \`${proposal}\`\n`); + } + } + return { + data, + dispose: () => { } + }; + } +} + +Registry.as(Extensions.ExtensionFeaturesRegistry).registerExtensionFeature({ + id: 'enabledApiProposals', + label: localize('enabledProposedAPIs', "API Proposals"), + access: { + canToggle: false + }, + renderer: new SyncDescriptor(ApiProposalsMarkdowneRenderer), +});