diff --git a/src/vs/code/node/cli.ts b/src/vs/code/node/cli.ts index af810fc65cc..c1285622ed0 100644 --- a/src/vs/code/node/cli.ts +++ b/src/vs/code/node/cli.ts @@ -23,7 +23,6 @@ function shouldSpawnCliProcess(argv: NativeParsedArgs): boolean { return !!argv['install-source'] || !!argv['list-extensions'] || !!argv['install-extension'] - || !!argv['install-builtin-extension'] || !!argv['uninstall-extension'] || !!argv['locate-extension'] || !!argv['telemetry']; diff --git a/src/vs/platform/extensionManagement/common/extensionManagement.ts b/src/vs/platform/extensionManagement/common/extensionManagement.ts index 50531800e36..e8c40c65d8e 100644 --- a/src/vs/platform/extensionManagement/common/extensionManagement.ts +++ b/src/vs/platform/extensionManagement/common/extensionManagement.ts @@ -104,6 +104,7 @@ export interface ILocalExtension extends IExtension { isMachineScoped: boolean; publisherId: string | null; publisherDisplayName: string | null; + installedTimestamp?: number; } export const enum SortBy { diff --git a/src/vs/platform/extensionManagement/node/extensionManagementService.ts b/src/vs/platform/extensionManagement/node/extensionManagementService.ts index 953b17f3b7f..45fe092c59e 100644 --- a/src/vs/platform/extensionManagement/node/extensionManagementService.ts +++ b/src/vs/platform/extensionManagement/node/extensionManagementService.ts @@ -48,6 +48,8 @@ import { IExtensionManifest, ExtensionType } from 'vs/platform/extensions/common import { ExtensionsDownloader } from 'vs/platform/extensionManagement/node/extensionDownloader'; import { ExtensionsScanner, ILocalExtensionManifest, IMetadata } from 'vs/platform/extensionManagement/node/extensionsScanner'; import { ExtensionsLifecycle } from 'vs/platform/extensionManagement/node/extensionLifecycle'; +import { ExtensionsWatcher } from 'vs/platform/extensionManagement/node/extensionsWatcher'; +import { IFileService } from 'vs/platform/files/common/files'; const INSTALL_ERROR_UNSET_UNINSTALLED = 'unsetUninstalled'; const INSTALL_ERROR_DOWNLOADING = 'downloading'; @@ -92,12 +94,19 @@ export class ExtensionManagementService extends Disposable implements IExtension @optional(IDownloadService) private downloadService: IDownloadService, @ITelemetryService private readonly telemetryService: ITelemetryService, @IInstantiationService instantiationService: IInstantiationService, + @IFileService fileService: IFileService, ) { super(); const extensionLifecycle = this._register(instantiationService.createInstance(ExtensionsLifecycle)); this.extensionsScanner = this._register(instantiationService.createInstance(ExtensionsScanner, extension => extensionLifecycle.postUninstall(extension))); this.manifestCache = this._register(new ExtensionsManifestCache(environmentService, this)); this.extensionsDownloader = this._register(instantiationService.createInstance(ExtensionsDownloader)); + const extensionsWatcher = this._register(new ExtensionsWatcher(this, fileService, environmentService, logService)); + + this._register(extensionsWatcher.onDidChangeExtensionsByAnotherSource(({ added, removed }) => { + added.forEach(extension => this._onDidInstallExtension.fire({ identifier: extension.identifier, operation: InstallOperation.None, local: extension })); + removed.forEach(extension => this._onDidUninstallExtension.fire({ identifier: extension })); + })); this._register(toDisposable(() => { this.installingExtensions.forEach(promise => promise.cancel()); diff --git a/src/vs/platform/extensionManagement/node/extensionsScanner.ts b/src/vs/platform/extensionManagement/node/extensionsScanner.ts index b5d716d60ab..b01379591cb 100644 --- a/src/vs/platform/extensionManagement/node/extensionsScanner.ts +++ b/src/vs/platform/extensionManagement/node/extensionsScanner.ts @@ -35,7 +35,8 @@ const INSTALL_ERROR_EXTRACTING = 'extracting'; const INSTALL_ERROR_DELETING = 'deleting'; const INSTALL_ERROR_RENAMING = 'renaming'; -export type IMetadata = Partial; +export type IMetadata = Partial; +type IStoredMetadata = IMetadata & { installedTimestamp: number | undefined }; export type ILocalExtensionManifest = IExtensionManifest & { __metadata?: IMetadata }; type IRelaxedLocalExtension = Omit & { isBuiltin: boolean }; @@ -115,6 +116,12 @@ export class ExtensionsScanner extends Disposable { } await this.extractAtLocation(identifierWithVersion, zipPath, tempPath, token); + let local = await this.scanExtension(URI.file(tempPath), ExtensionType.User); + if (!local) { + throw new Error(localize('cannot read', "Cannot read the extension from {0}", tempPath)); + } + await this.storeMetadata(local, { installedTimestamp: Date.now() }); + try { await this.rename(identifierWithVersion, tempPath, extensionPath, Date.now() + (2 * 60 * 1000) /* Retry for 2 minutes */); this.logService.info('Renamed to', extensionPath); @@ -130,9 +137,8 @@ export class ExtensionsScanner extends Disposable { } } - let local: ILocalExtension | null = null; try { - local = await this.scanExtension(URI.file(path.join(this.extensionsPath, folderName)), ExtensionType.User); + local = await this.scanExtension(URI.file(extensionPath), ExtensionType.User); } catch (e) { /*ignore */ } if (local) { @@ -143,14 +149,19 @@ export class ExtensionsScanner extends Disposable { async saveMetadataForLocalExtension(local: ILocalExtension, metadata: IMetadata): Promise { this.setMetadata(local, metadata); + await this.storeMetadata(local, { ...metadata, installedTimestamp: local.installedTimestamp }); + return local; + } + private async storeMetadata(local: ILocalExtension, storedMetadata: IStoredMetadata): Promise { // unset if false - metadata.isMachineScoped = metadata.isMachineScoped || undefined; - metadata.isBuiltin = metadata.isBuiltin || undefined; + storedMetadata.isMachineScoped = storedMetadata.isMachineScoped || undefined; + storedMetadata.isBuiltin = storedMetadata.isBuiltin || undefined; + storedMetadata.installedTimestamp = storedMetadata.installedTimestamp || undefined; const manifestPath = path.join(local.location.fsPath, 'package.json'); const raw = await fs.promises.readFile(manifestPath, 'utf8'); const { manifest } = await this.parseManifest(raw); - (manifest as ILocalExtensionManifest).__metadata = metadata; + (manifest as ILocalExtensionManifest).__metadata = storedMetadata; await pfs.writeFile(manifestPath, JSON.stringify(manifest, null, '\t')); return local; } @@ -249,16 +260,18 @@ export class ExtensionsScanner extends Disposable { const stat = await this.fileService.resolve(URI.file(dir)); if (stat.children) { const extensions = await Promise.all(stat.children.filter(c => c.isDirectory) - .map(c => limiter.queue(() => this.scanExtension(c.resource, type)))); + .map(c => limiter.queue(async () => { + if (type === ExtensionType.User && basename(c.resource).indexOf('.') === 0) { // Do not consider user extension folder starting with `.` + return null; + } + return this.scanExtension(c.resource, type); + }))); return extensions.filter(e => e && e.identifier); } return []; } private async scanExtension(extensionLocation: URI, type: ExtensionType): Promise { - if (type === ExtensionType.User && basename(extensionLocation).indexOf('.') === 0) { // Do not consider user extension folder starting with `.` - return null; - } try { const stat = await this.fileService.resolve(extensionLocation); if (stat.children) { @@ -269,6 +282,7 @@ export class ExtensionsScanner extends Disposable { const local = { type, identifier, manifest, location: extensionLocation, readmeUrl, changelogUrl, publisherDisplayName: null, publisherId: null, isMachineScoped: false, isBuiltin: type === ExtensionType.System }; if (metadata) { this.setMetadata(local, metadata); + local.installedTimestamp = metadata.installedTimestamp; } return local; } @@ -358,7 +372,7 @@ export class ExtensionsScanner extends Disposable { return this._devSystemExtensionsPath; } - private async readManifest(extensionPath: string): Promise<{ manifest: IExtensionManifest; metadata: IMetadata | null; }> { + private async readManifest(extensionPath: string): Promise<{ manifest: IExtensionManifest; metadata: IStoredMetadata | null; }> { const promises = [ fs.promises.readFile(path.join(extensionPath, 'package.json'), 'utf8') .then(raw => this.parseManifest(raw)), diff --git a/src/vs/platform/extensionManagement/node/extensionsWatcher.ts b/src/vs/platform/extensionManagement/node/extensionsWatcher.ts new file mode 100644 index 00000000000..1b9ada04b1c --- /dev/null +++ b/src/vs/platform/extensionManagement/node/extensionsWatcher.ts @@ -0,0 +1,93 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from 'vs/base/common/lifecycle'; +import { INativeEnvironmentService } from 'vs/platform/environment/common/environment'; +import { IExtensionManagementService, ILocalExtension } from 'vs/platform/extensionManagement/common/extensionManagement'; +import { Emitter, Event } from 'vs/base/common/event'; +import { ExtensionType, IExtensionIdentifier } from 'vs/platform/extensions/common/extensions'; +import { FileChangeType, FileSystemProviderCapabilities, IFileService } from 'vs/platform/files/common/files'; +import { URI } from 'vs/base/common/uri'; +import { areSameExtensions } from 'vs/platform/extensionManagement/common/extensionManagementUtil'; +import { ExtUri } from 'vs/base/common/resources'; +import { ILogService } from 'vs/platform/log/common/log'; + +export class ExtensionsWatcher extends Disposable { + + private readonly _onDidChangeExtensionsByAnotherSource = this._register(new Emitter<{ added: ILocalExtension[], removed: IExtensionIdentifier[] }>()); + readonly onDidChangeExtensionsByAnotherSource = this._onDidChangeExtensionsByAnotherSource.event; + + private startTimestamp = 0; + private extensions: IExtensionIdentifier[] | undefined; + + constructor( + private readonly extensionsManagementService: IExtensionManagementService, + @IFileService fileService: IFileService, + @INativeEnvironmentService environmentService: INativeEnvironmentService, + @ILogService private readonly logService: ILogService, + ) { + super(); + this.extensionsManagementService.getInstalled(ExtensionType.User).then(extensions => { + this.extensions = extensions.map(e => e.identifier); + this.startTimestamp = Date.now(); + }); + this._register(extensionsManagementService.onInstallExtension(e => this.add(e.identifier))); + this._register(Event.filter(extensionsManagementService.onDidInstallExtension, e => !!e.error)(e => this.remove(e.identifier))); + this._register(Event.filter(extensionsManagementService.onDidUninstallExtension, e => !e.error)(e => this.remove(e.identifier))); + + const extensionsResource = URI.file(environmentService.extensionsPath); + const extUri = new ExtUri(resource => !fileService.hasCapability(resource, FileSystemProviderCapabilities.PathCaseSensitive)); + this._register(fileService.watch(extensionsResource)); + this._register(Event.filter(fileService.onDidFilesChange, + e => e.changes.some(change => + extUri.isEqual(extUri.dirname(change.resource), extensionsResource) // extensions dir is parent + && (change.type === FileChangeType.ADDED || change.type === FileChangeType.DELETED) // file added or removed + && !extUri.basename(change.resource).startsWith('.') // ignore changes to files starting with `.` + )) + (() => this.onDidChange())); + } + + private add(extension: IExtensionIdentifier): void { + if (this.extensions) { + this.remove(extension); + this.extensions.push(extension); + } + } + + private remove(identifier: IExtensionIdentifier): void { + if (this.extensions) { + this.extensions = this.extensions.filter(e => !areSameExtensions(e, identifier)); + } + } + + private async onDidChange(): Promise { + if (this.extensions) { + const extensions = await this.extensionsManagementService.getInstalled(ExtensionType.User); + const added = extensions.filter(e => { + if (this.extensions!.every(identifier => !areSameExtensions(e.identifier, identifier))) { + if (e.installedTimestamp && e.installedTimestamp > this.startTimestamp) { + this.logService.info('Detected extension installed from another source', e.identifier.id); + return true; + } else { + this.logService.info('Ignored extension installed by another source because of invalid timestamp', e.identifier.id); + } + } + return false; + }); + const removed = this.extensions.filter(identifier => { + if (extensions.every(e => !areSameExtensions(e.identifier, identifier))) { + this.logService.info('Detected extension removed from another source', identifier.id); + return true; + } + return false; + }); + this.extensions = extensions.map(e => e.identifier); + if (added.length || removed.length) { + this._onDidChangeExtensionsByAnotherSource.fire({ added, removed }); + } + } + } + +}