diff --git a/src/vs/platform/extensionManagement/node/extensionLifecycle.ts b/src/vs/platform/extensionManagement/node/extensionLifecycle.ts new file mode 100644 index 0000000000000..cbeef87b2bc7f --- /dev/null +++ b/src/vs/platform/extensionManagement/node/extensionLifecycle.ts @@ -0,0 +1,123 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +'use strict'; + +import { Disposable } from 'vs/base/common/lifecycle'; +import { ILocalExtension } from 'vs/platform/extensionManagement/common/extensionManagement'; +import { TPromise } from 'vs/base/common/winjs.base'; +import { ILogService } from 'vs/platform/log/common/log'; +import { fork, ChildProcess } from 'child_process'; +import { toErrorMessage } from 'vs/base/common/errorMessage'; +import { join } from 'vs/base/common/paths'; +import { Limiter } from 'vs/base/common/async'; +import { fromNodeEventEmitter, anyEvent, mapEvent, debounceEvent } from 'vs/base/common/event'; + +export class ExtensionsLifecycle extends Disposable { + + private processesLimiter: Limiter = new Limiter(5); // Run max 5 processes in parallel + + constructor( + @ILogService private logService: ILogService + ) { + super(); + } + + uninstall(extension: ILocalExtension): TPromise { + const uninstallScript = this.parseUninstallScript(extension); + if (uninstallScript) { + this.logService.info(extension.identifier.id, 'Running Uninstall hook'); + return this.processesLimiter.queue(() => + this.runUninstallHook(uninstallScript.uninstallHook, uninstallScript.args, extension) + .then(() => this.logService.info(extension.identifier.id, 'Finished running uninstall hook'), err => this.logService.error(extension.identifier.id, `Failed to run uninstall hook: ${err}`))); + } + return TPromise.as(null); + } + + private parseUninstallScript(extension: ILocalExtension): { uninstallHook: string, args: string[] } { + if (extension.manifest && extension.manifest['scripts'] && typeof extension.manifest['scripts']['vscode:uninstall'] === 'string') { + const uninstallScript = (extension.manifest['scripts']['vscode:uninstall']).split(' '); + if (uninstallScript.length < 2 || uninstallScript[0] !== 'node' || !uninstallScript[1]) { + this.logService.warn(extension.identifier.id, 'Uninstall script should be a node script'); + return null; + } + return { uninstallHook: join(extension.path, uninstallScript[1]), args: uninstallScript.slice(2) || [] }; + } + return null; + } + + private runUninstallHook(lifecycleHook: string, args: string[], extension: ILocalExtension): TPromise { + return new TPromise((c, e) => { + + const extensionLifecycleProcess = this.start(lifecycleHook, args, extension); + let timeoutHandler; + + const onexit = (error?: string) => { + clearTimeout(timeoutHandler); + timeoutHandler = null; + if (error) { + e(error); + } else { + c(null); + } + }; + + // on error + extensionLifecycleProcess.on('error', (err) => { + if (timeoutHandler) { + onexit(toErrorMessage(err) || 'Unknown'); + } + }); + + // on exit + extensionLifecycleProcess.on('exit', (code: number, signal: string) => { + if (timeoutHandler) { + onexit(code ? `Process exited with code ${code}` : void 0); + } + }); + + // timeout: kill process after waiting for 5s + timeoutHandler = setTimeout(() => { + timeoutHandler = null; + extensionLifecycleProcess.kill(); + e('timed out'); + }, 5000); + }); + } + + private start(uninstallHook: string, args: string[], extension: ILocalExtension): ChildProcess { + const opts = { + silent: true, + execArgv: undefined + }; + const extensionUninstallProcess = fork(uninstallHook, ['--type=extensionUninstall', ...args], opts); + + // Catch all output coming from the process + type Output = { data: string, format: string[] }; + extensionUninstallProcess.stdout.setEncoding('utf8'); + extensionUninstallProcess.stderr.setEncoding('utf8'); + const onStdout = fromNodeEventEmitter(extensionUninstallProcess.stdout, 'data'); + const onStderr = fromNodeEventEmitter(extensionUninstallProcess.stderr, 'data'); + const onOutput = anyEvent( + mapEvent(onStdout, o => ({ data: `%c${o}`, format: [''] })), + mapEvent(onStderr, o => ({ data: `%c${o}`, format: ['color: red'] })) + ); + + // Debounce all output, so we can render it in the Chrome console as a group + const onDebouncedOutput = debounceEvent(onOutput, (r, o) => { + return r + ? { data: r.data + o.data, format: [...r.format, ...o.format] } + : { data: o.data, format: o.format }; + }, 100); + + // Print out extension host output + onDebouncedOutput(data => { + console.group(extension.identifier.id); + console.log(data.data, ...data.format); + console.groupEnd(); + }); + + return extensionUninstallProcess; + } +} diff --git a/src/vs/platform/extensionManagement/node/extensionManagementService.ts b/src/vs/platform/extensionManagement/node/extensionManagementService.ts index 718e8fd1b3d49..a102d12a254ff 100644 --- a/src/vs/platform/extensionManagement/node/extensionManagementService.ts +++ b/src/vs/platform/extensionManagement/node/extensionManagementService.ts @@ -35,6 +35,7 @@ import { ILogService } from 'vs/platform/log/common/log'; import { ExtensionsManifestCache } from 'vs/platform/extensionManagement/node/extensionsManifestCache'; import { IChoiceService } from 'vs/platform/dialogs/common/dialogs'; import Severity from 'vs/base/common/severity'; +import { ExtensionsLifecycle } from 'vs/platform/extensionManagement/node/extensionLifecycle'; const SystemExtensionsRoot = path.normalize(path.join(URI.parse(require.toUrl('')).fsPath, '..', 'extensions')); const ERROR_SCANNING_SYS_EXTENSIONS = 'scanningSystem'; @@ -110,6 +111,7 @@ export class ExtensionManagementService extends Disposable implements IExtension private lastReportTimestamp = 0; private readonly installingExtensions: Map> = new Map>(); private readonly manifestCache: ExtensionsManifestCache; + private readonly extensionLifecycle: ExtensionsLifecycle; private readonly _onInstallExtension = new Emitter(); readonly onInstallExtension: Event = this._onInstallExtension.event; @@ -135,6 +137,7 @@ export class ExtensionManagementService extends Disposable implements IExtension this.uninstalledFileLimiter = new Limiter(1); this._register(toDisposable(() => this.installingExtensions.clear())); this.manifestCache = this._register(new ExtensionsManifestCache(environmentService, this)); + this.extensionLifecycle = this._register(new ExtensionsLifecycle(this.logService)); } install(zipPath: string): TPromise { @@ -708,24 +711,43 @@ export class ExtensionManagementService extends Disposable implements IExtension } removeDeprecatedExtensions(): TPromise { + return this.removeUninstalledExtensions() + .then(() => this.removeOutdatedExtensions()); + } + + private removeUninstalledExtensions(): TPromise { return this.getUninstalledExtensions() .then(uninstalled => this.scanExtensions(this.extensionsPath, LocalExtensionType.User) // All user extensions .then(extensions => { - const toRemove: { path: string, id: string }[] = []; - // Uninstalled extensions - toRemove.push(...extensions.filter(e => uninstalled[e.identifier.id]).map(e => ({ path: e.path, id: e.identifier.id }))); + const toRemove: ILocalExtension[] = extensions.filter(e => uninstalled[e.identifier.id]); + return TPromise.join(toRemove.map(e => this.removeUninstalledExtension(e))); + }) + ).then(() => null); + } - // Outdated extensions - const byExtension: ILocalExtension[][] = groupByExtension(extensions, e => ({ id: getGalleryExtensionIdFromLocal(e), uuid: e.identifier.uuid })); - toRemove.push(...flatten(byExtension.map(p => p.sort((a, b) => semver.rcompare(a.manifest.version, b.manifest.version)).slice(1))) - .map(e => ({ path: e.path, id: e.identifier.id }))); + private removeOutdatedExtensions(): TPromise { + return this.scanExtensions(this.extensionsPath, LocalExtensionType.User) // All user extensions + .then(extensions => { + const toRemove: ILocalExtension[] = []; - return TPromise.join(distinct(toRemove, e => e.path).map(({ path, id }) => { - return pfs.rimraf(path) - .then(() => this.withUninstalledExtensions(uninstalled => delete uninstalled[id])); - })); - }) - ); + // Outdated extensions + const byExtension: ILocalExtension[][] = groupByExtension(extensions, e => ({ id: getGalleryExtensionIdFromLocal(e), uuid: e.identifier.uuid })); + toRemove.push(...flatten(byExtension.map(p => p.sort((a, b) => semver.rcompare(a.manifest.version, b.manifest.version)).slice(1)))); + + return TPromise.join(toRemove.map(extension => this.removeExtension(extension))); + }).then(() => null); + } + + private removeUninstalledExtension(extension: ILocalExtension): TPromise { + return this.extensionLifecycle.uninstall(extension) + .then(() => this.removeExtension(extension)) + .then(() => this.withUninstalledExtensions(uninstalled => delete uninstalled[extension.identifier.id])) + .then(() => null); + } + + private removeExtension(extension: ILocalExtension): TPromise { + this.logService.trace(extension.identifier.id, 'Deleting from disk'); + return pfs.rimraf(extension.path).then(() => this.logService.info(extension.identifier.id, 'Deleted from disk')); } private isUninstalled(id: string): TPromise { diff --git a/src/vs/platform/extensions/common/extensionsRegistry.ts b/src/vs/platform/extensions/common/extensionsRegistry.ts index d5cf06b32c300..c230839ad34ef 100644 --- a/src/vs/platform/extensions/common/extensionsRegistry.ts +++ b/src/vs/platform/extensions/common/extensionsRegistry.ts @@ -264,6 +264,10 @@ const schema: IJSONSchema = { 'vscode:prepublish': { description: nls.localize('vscode.extension.scripts.prepublish', 'Script executed before the package is published as a VS Code extension.'), type: 'string' + }, + 'vscode:uninstall': { + description: nls.localize('vscode.extension.scripts.uninstall', 'Script executed after the extension is uninstalled from VS Code. Only Node script is supported.'), + type: 'string' } } },