From fc7ae121084454e3dbfbc017e04e0f47ac9e22b9 Mon Sep 17 00:00:00 2001 From: Colin Grant Date: Fri, 8 Apr 2022 09:15:12 -0600 Subject: [PATCH] Implement safe plugin uninstallation --- .vscode/settings.json | 1 + packages/core/src/common/promise-util.ts | 27 +++-- .../plugin-vscode-commands-contribution.ts | 3 +- .../src/common/plugin-vscode-uri.ts | 41 +++++++ .../node/plugin-vscode-directory-handler.ts | 62 +++++++++-- .../src/node/plugin-vscode-file-handler.ts | 26 ++++- .../src/node/scanner-vscode.ts | 8 +- packages/plugin-ext/package.json | 1 + .../src/common/plugin-identifiers.ts | 84 ++++++++++++++ .../plugin-ext/src/common/plugin-protocol.ts | 40 +++++-- .../src/hosted/browser/hosted-plugin.ts | 38 ++++--- .../browser/worker/plugin-manifest-loader.ts | 6 +- .../node/hosted-plugin-deployer-handler.ts | 84 ++++++++++---- .../src/hosted/node/hosted-plugin-process.ts | 4 +- .../src/hosted/node/hosted-plugin.ts | 4 +- .../src/hosted/node/metadata-scanner.ts | 14 ++- .../src/hosted/node/plugin-manifest-loader.ts | 2 + .../src/hosted/node/plugin-service.ts | 102 +++++++++++++---- .../src/hosted/node/scanners/scanner-theia.ts | 8 +- .../plugin-theia-directory-handler.ts | 84 +++++++++----- .../handlers/plugin-theia-file-handler.ts | 29 ++++- .../src/main/node/plugin-cli-contribution.ts | 12 ++ ...deployer-directory-handler-context-impl.ts | 18 ++- ...ugin-deployer-file-handler-context-impl.ts | 1 - .../src/main/node/plugin-deployer-impl.ts | 105 +++++++++++++----- .../main/node/plugin-ext-backend-module.ts | 3 + .../src/main/node/plugin-server-handler.ts | 8 +- .../node/plugin-uninstallation-manager.ts | 60 ++++++++++ .../browser/vsx-extension-editor-manager.ts | 6 +- .../src/browser/vsx-extension.tsx | 57 +++++++--- .../src/common/vsx-extension-uri.ts | 15 +-- .../src/node/vsx-extension-resolver.ts | 14 +-- 32 files changed, 752 insertions(+), 215 deletions(-) create mode 100644 packages/plugin-ext-vscode/src/common/plugin-vscode-uri.ts create mode 100644 packages/plugin-ext/src/common/plugin-identifiers.ts create mode 100644 packages/plugin-ext/src/main/node/plugin-uninstallation-manager.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 2b4473523d78a..5b108a74ccc8b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -58,4 +58,5 @@ "editor.rulers": [ 180 ], + "typescript.preferences.quoteStyle": "single", } diff --git a/packages/core/src/common/promise-util.ts b/packages/core/src/common/promise-util.ts index 095f73680e8a9..1234828aeb33b 100644 --- a/packages/core/src/common/promise-util.ts +++ b/packages/core/src/common/promise-util.ts @@ -25,22 +25,21 @@ import { CancellationToken, CancellationError, cancelled } from './cancellation' export class Deferred { state: 'resolved' | 'rejected' | 'unresolved' = 'unresolved'; resolve: (value: T | PromiseLike) => void; - reject: (err?: any) => void; // eslint-disable-line @typescript-eslint/no-explicit-any + reject: (err?: unknown) => void; promise = new Promise((resolve, reject) => { - this.resolve = result => { - resolve(result); - if (this.state === 'unresolved') { - this.state = 'resolved'; - } - }; - this.reject = err => { - reject(err); - if (this.state === 'unresolved') { - this.state = 'rejected'; - } - }; - }); + this.resolve = resolve; + this.reject = reject; + }).then( + res => (this.setState('resolved'), res), + err => (this.setState('rejected'), Promise.reject(err)), + ); + + protected setState(state: 'resolved' | 'rejected'): void { + if (this.state === 'unresolved') { + this.state = state; + } + } } /** diff --git a/packages/plugin-ext-vscode/src/browser/plugin-vscode-commands-contribution.ts b/packages/plugin-ext-vscode/src/browser/plugin-vscode-commands-contribution.ts index e49d0f0a2a5dd..c73af701d0010 100755 --- a/packages/plugin-ext-vscode/src/browser/plugin-vscode-commands-contribution.ts +++ b/packages/plugin-ext-vscode/src/browser/plugin-vscode-commands-contribution.ts @@ -78,6 +78,7 @@ import { CustomEditorOpener } from '@theia/plugin-ext/lib/main/browser/custom-ed import { nls } from '@theia/core/lib/common/nls'; import { WindowService } from '@theia/core/lib/browser/window/window-service'; import * as monaco from '@theia/monaco-editor-core'; +import { VSCodeExtensionUri } from '../common/plugin-vscode-uri'; export namespace VscodeCommands { export const OPEN: Command = { @@ -305,7 +306,7 @@ export class PluginVscodeCommandsContribution implements CommandContribution { commands.registerCommand({ id: VscodeCommands.INSTALL_FROM_VSIX.id }, { execute: async (vsixUriOrExtensionId: TheiaURI | UriComponents | string) => { if (typeof vsixUriOrExtensionId === 'string') { - await this.pluginServer.deploy(`vscode:extension/${vsixUriOrExtensionId}`); + await this.pluginServer.deploy(VSCodeExtensionUri.toVsxExtensionUriString(vsixUriOrExtensionId)); } else { const uriPath = isUriComponents(vsixUriOrExtensionId) ? URI.revive(vsixUriOrExtensionId).fsPath : await this.fileService.fsPath(vsixUriOrExtensionId); await this.pluginServer.deploy(`local-file:${uriPath}`); diff --git a/packages/plugin-ext-vscode/src/common/plugin-vscode-uri.ts b/packages/plugin-ext-vscode/src/common/plugin-vscode-uri.ts new file mode 100644 index 0000000000000..ddcbb76c4cb93 --- /dev/null +++ b/packages/plugin-ext-vscode/src/common/plugin-vscode-uri.ts @@ -0,0 +1,41 @@ +// ***************************************************************************** +// Copyright (C) 2022 Ericsson and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 +// ***************************************************************************** + +import URI from '@theia/core/lib/common/uri'; + +/** + * Static methods for identifying a plugin as the target of the VSCode deployment system. + * In practice, this means that it will be resolved and deployed by the Open-VSX system. + */ +export namespace VSCodeExtensionUri { + export const VSCODE_PREFIX = 'vscode:extension/'; + /** + * Should be used to prefix a plugin's ID to ensure that it is identified as a VSX Extension. + * @returns `vscode:extension/${id}` + */ + export function toVsxExtensionUriString(id: string): string { + return `${VSCODE_PREFIX}${id}`; + } + export function toUri(id: string): URI { + return new URI(toVsxExtensionUriString(id)); + } + export function toId(uri: URI): string | undefined { + if (uri.scheme === 'vscode' && uri.path.dir.toString() === 'extension') { + return uri.path.base; + } + return undefined; + } +} diff --git a/packages/plugin-ext-vscode/src/node/plugin-vscode-directory-handler.ts b/packages/plugin-ext-vscode/src/node/plugin-vscode-directory-handler.ts index 56dc96d17401b..bf50cf2771f3c 100644 --- a/packages/plugin-ext-vscode/src/node/plugin-vscode-directory-handler.ts +++ b/packages/plugin-ext-vscode/src/node/plugin-vscode-directory-handler.ts @@ -14,28 +14,70 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 // ***************************************************************************** -import * as fs from '@theia/core/shared/fs-extra'; import * as path from 'path'; -import { injectable } from '@theia/core/shared/inversify'; +import * as filenamify from 'filenamify'; +import * as fs from '@theia/core/shared/fs-extra'; +import { inject, injectable } from '@theia/core/shared/inversify'; import { RecursivePartial } from '@theia/core'; import { - PluginDeployerDirectoryHandler, - PluginDeployerEntry, PluginDeployerDirectoryHandlerContext, - PluginDeployerEntryType, PluginPackage + PluginDeployerDirectoryHandler, PluginDeployerEntry, PluginDeployerDirectoryHandlerContext, + PluginDeployerEntryType, PluginPackage, PluginType, PluginIdentifiers } from '@theia/plugin-ext'; +import { FileUri } from '@theia/core/lib/node'; +import { getTempDir } from '@theia/plugin-ext/lib/main/node/temp-dir-util'; +import { PluginCliContribution } from '@theia/plugin-ext/lib/main/node/plugin-cli-contribution'; @injectable() export class PluginVsCodeDirectoryHandler implements PluginDeployerDirectoryHandler { + protected readonly deploymentDirectory = FileUri.create(getTempDir('vscode-copied')); + + @inject(PluginCliContribution) protected readonly pluginCli: PluginCliContribution; + accept(plugin: PluginDeployerEntry): boolean { console.debug(`Resolving "${plugin.id()}" as a VS Code extension...`); - return this.resolvePackage(plugin) || this.resolveFromSources(plugin) || this.resolveFromVSIX(plugin) || this.resolveFromNpmTarball(plugin); + return this.attemptResolution(plugin); + } + + protected attemptResolution(plugin: PluginDeployerEntry): boolean { + return this.resolvePackage(plugin) || this.deriveMetadata(plugin); + } + + protected deriveMetadata(plugin: PluginDeployerEntry): boolean { + return this.resolveFromSources(plugin) || this.resolveFromVSIX(plugin) || this.resolveFromNpmTarball(plugin); } async handle(context: PluginDeployerDirectoryHandlerContext): Promise { + await this.copyDirectory(context); context.pluginEntry().accept(PluginDeployerEntryType.BACKEND); } + protected async copyDirectory(context: PluginDeployerDirectoryHandlerContext): Promise { + if (this.pluginCli.copyUncompressedPlugins() && context.pluginEntry().type === PluginType.User) { + const entry = context.pluginEntry(); + const id = entry.id(); + const pathToRestore = entry.path(); + const origin = entry.originalPath(); + const targetDir = await this.getExtensionDir(context); + try { + if (fs.existsSync(targetDir) || !entry.path().startsWith(origin)) { + console.log(`[${id}]: already copied.`); + } else { + console.log(`[${id}]: copying to "${targetDir}"`); + await fs.mkdirp(FileUri.fsPath(this.deploymentDirectory)); + await context.copy(origin, targetDir); + entry.updatePath(targetDir); + if (!this.deriveMetadata(entry)) { + throw new Error('Unable to resolve plugin metadata after copying'); + } + } + } catch (e) { + console.warn(`[${id}]: Error when copying.`, e); + entry.updatePath(pathToRestore); + } + } + } + protected resolveFromSources(plugin: PluginDeployerEntry): boolean { const pluginPath = plugin.path(); return this.resolvePackage(plugin, { pluginPath, pck: this.requirePackage(pluginPath) }); @@ -65,6 +107,7 @@ export class PluginVsCodeDirectoryHandler implements PluginDeployerDirectoryHand if (!pck || !pck.name || !pck.version || !pck.engines || !pck.engines.vscode) { return false; } + pck.publisher ??= PluginIdentifiers.UNPUBLISHED; if (options) { plugin.storeValue('package.json', pck); plugin.rootPath = plugin.path(); @@ -76,10 +119,15 @@ export class PluginVsCodeDirectoryHandler implements PluginDeployerDirectoryHand protected requirePackage(pluginPath: string): PluginPackage | undefined { try { - return fs.readJSONSync(path.join(pluginPath, 'package.json')); + const plugin = fs.readJSONSync(path.join(pluginPath, 'package.json')) as PluginPackage; + plugin.publisher ??= PluginIdentifiers.UNPUBLISHED; + return plugin; } catch { return undefined; } } + protected async getExtensionDir(context: PluginDeployerDirectoryHandlerContext): Promise { + return FileUri.fsPath(this.deploymentDirectory.resolve(filenamify(context.pluginEntry().id(), { replacement: '_' }))); + } } diff --git a/packages/plugin-ext-vscode/src/node/plugin-vscode-file-handler.ts b/packages/plugin-ext-vscode/src/node/plugin-vscode-file-handler.ts index 8467b912033a4..abd9670d64a83 100644 --- a/packages/plugin-ext-vscode/src/node/plugin-vscode-file-handler.ts +++ b/packages/plugin-ext-vscode/src/node/plugin-vscode-file-handler.ts @@ -22,6 +22,7 @@ import { injectable, inject } from '@theia/core/shared/inversify'; import { getTempDir } from '@theia/plugin-ext/lib/main/node/temp-dir-util'; import { PluginVSCodeEnvironment } from '../common/plugin-vscode-environment'; import { FileUri } from '@theia/core/lib/node/file-uri'; +import URI from '@theia/core/lib/common/uri'; @injectable() export class PluginVsCodeFileHandler implements PluginDeployerFileHandler { @@ -40,6 +41,7 @@ export class PluginVsCodeFileHandler implements PluginDeployerFileHandler { } async handle(context: PluginDeployerFileHandlerContext): Promise { + await this.ensureDiscoverability(context); const id = context.pluginEntry().id(); const extensionDir = await this.getExtensionDir(context); console.log(`[${id}]: trying to decompress into "${extensionDir}"...`); @@ -54,11 +56,29 @@ export class PluginVsCodeFileHandler implements PluginDeployerFileHandler { } protected async getExtensionDir(context: PluginDeployerFileHandlerContext): Promise { - let extensionsDirUri = this.systemExtensionsDirUri; + return FileUri.fsPath(this.systemExtensionsDirUri.resolve(filenamify(context.pluginEntry().id(), { replacement: '_' }))); + } + + /** + * Ensures that a user-installed plugin file is transferred to the user extension folder. + */ + protected async ensureDiscoverability(context: PluginDeployerFileHandlerContext): Promise { if (context.pluginEntry().type === PluginType.User) { - extensionsDirUri = await this.environment.getExtensionsDirUri(); + const userExtensionsDir = await this.environment.getExtensionsDirUri(); + const currentPath = context.pluginEntry().path(); + if (!userExtensionsDir.isEqualOrParent(new URI(currentPath)) && !userExtensionsDir.isEqualOrParent(new URI(context.pluginEntry().originalPath()))) { + try { + const newPath = FileUri.fsPath(userExtensionsDir.resolve(path.basename(currentPath))); + await fs.mkdirp(FileUri.fsPath(userExtensionsDir)); + await new Promise((resolve, reject) => { + fs.copyFile(currentPath, newPath, error => error ? reject(error) : resolve()); + }); + context.pluginEntry().updatePath(newPath); + } catch (e) { + console.error(`[${context.pluginEntry().id}]: Failed to copy to user directory. Future sessions may not have access to this plugin.`); + } + } } - return FileUri.fsPath(extensionsDirUri.resolve(filenamify(context.pluginEntry().id(), { replacement: '_' }))); } protected async decompress(extensionDir: string, context: PluginDeployerFileHandlerContext): Promise { diff --git a/packages/plugin-ext-vscode/src/node/scanner-vscode.ts b/packages/plugin-ext-vscode/src/node/scanner-vscode.ts index c5eb86116e2c0..9101b39192993 100644 --- a/packages/plugin-ext-vscode/src/node/scanner-vscode.ts +++ b/packages/plugin-ext-vscode/src/node/scanner-vscode.ts @@ -16,9 +16,10 @@ import * as path from 'path'; import { injectable } from '@theia/core/shared/inversify'; -import { PluginScanner, PluginEngine, PluginPackage, PluginModel, PluginLifecycle, PluginEntryPoint, buildFrontendModuleName, UIKind } from '@theia/plugin-ext'; +import { PluginScanner, PluginEngine, PluginPackage, PluginModel, PluginLifecycle, PluginEntryPoint, buildFrontendModuleName, UIKind, PluginIdentifiers } from '@theia/plugin-ext'; import { TheiaPluginScanner } from '@theia/plugin-ext/lib/hosted/node/scanners/scanner-theia'; import { environment } from '@theia/core/shared/@theia/application-package/lib/environment'; +import { VSCodeExtensionUri } from '../common/plugin-vscode-uri'; const uiKind = environment.electron.is() ? UIKind.Desktop : UIKind.Web; @@ -26,7 +27,6 @@ const uiKind = environment.electron.is() ? UIKind.Desktop : UIKind.Web; export class VsCodePluginScanner extends TheiaPluginScanner implements PluginScanner { private readonly VSCODE_TYPE: PluginEngine = 'vscode'; - private readonly VSCODE_PREFIX: string = 'vscode:extension/'; override get apiType(): PluginEngine { return this.VSCODE_TYPE; @@ -34,7 +34,7 @@ export class VsCodePluginScanner extends TheiaPluginScanner implements PluginSca override getModel(plugin: PluginPackage): PluginModel { // publisher can be empty on vscode extension development - const publisher = plugin.publisher || ''; + const publisher = plugin.publisher ?? PluginIdentifiers.UNPUBLISHED; // Only one entrypoint is valid in vscode extensions // Mimic choosing frontend (web extension) and backend (local/remote extension) as described here: @@ -86,7 +86,7 @@ export class VsCodePluginScanner extends TheiaPluginScanner implements PluginSca // Iterate over the list of dependencies present, and add them to the collection. dependency.forEach((dep: string) => { const dependencyId = dep.toLowerCase(); - dependencies.set(dependencyId, this.VSCODE_PREFIX + dependencyId); + dependencies.set(dependencyId, VSCodeExtensionUri.toVsxExtensionUriString(dependencyId)); }); } } diff --git a/packages/plugin-ext/package.json b/packages/plugin-ext/package.json index 721291e027e3c..3331fd80aa51a 100644 --- a/packages/plugin-ext/package.json +++ b/packages/plugin-ext/package.json @@ -39,6 +39,7 @@ "mime": "^2.4.4", "ps-tree": "^1.2.0", "request": "^2.82.0", + "semver": "^5.4.1", "uuid": "^8.0.0", "vhost": "^3.0.2", "vscode-debugprotocol": "^1.32.0", diff --git a/packages/plugin-ext/src/common/plugin-identifiers.ts b/packages/plugin-ext/src/common/plugin-identifiers.ts new file mode 100644 index 0000000000000..9864832495081 --- /dev/null +++ b/packages/plugin-ext/src/common/plugin-identifiers.ts @@ -0,0 +1,84 @@ +// ***************************************************************************** +// Copyright (C) 2022 Ericsson and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 +// ***************************************************************************** + +export namespace PluginIdentifiers { + export interface Components { + publisher?: string; + name: string; + version: string; + } + + export interface IdAndVersion { + id: UnversionedId; + version: string; + } + + export type VersionedId = `${string}.${string}@${string}`; + export type UnversionedId = `${string}.${string}`; + /** Unpublished plugins (not from Open VSX or VSCode plugin store) may not have a `publisher` field. */ + export const UNPUBLISHED = ''; + + /** + * @returns a string in the format `.` + */ + export function componentsToUnversionedId({ publisher = UNPUBLISHED, name }: Components): UnversionedId { + return `${publisher.toLowerCase()}.${name.toLowerCase()}`; + } + /** + * @returns a string in the format `.@`. + */ + export function componentsToVersionedId({ publisher = UNPUBLISHED, name, version }: Components): VersionedId { + return `${publisher.toLowerCase()}.${name.toLowerCase()}@${version}`; + } + export function componentsToVersionWithId(components: Components): IdAndVersion { + return { id: componentsToUnversionedId(components), version: components.version }; + } + /** + * @returns a string in the format `@`. + */ + export function idAndVersionToVersionedId({ id, version }: IdAndVersion): VersionedId { + return `${id}@${version}`; + } + /** + * @returns a string in the format `.`. + */ + export function unversionedFromVersioned(id: VersionedId): UnversionedId { + const endOfId = id.indexOf('@'); + return id.slice(0, endOfId) as UnversionedId; + } + /** + * @returns `undefined` if it looks like the string passed in does not have the format returned by {@link PluginIdentifiers.toVersionedId}. + */ + export function identifiersFromVersionedId(probablyId: string): Components | undefined { + const endOfPublisher = probablyId.indexOf('.'); + const endOfName = probablyId.indexOf('@', endOfPublisher); + if (endOfPublisher === -1 || endOfName === -1) { + return undefined; + } + return { publisher: probablyId.slice(0, endOfPublisher), name: probablyId.slice(endOfPublisher + 1, endOfName), version: probablyId.slice(endOfName + 1) }; + } + /** + * @returns `undefined` if it looks like the string passed in does not have the format returned by {@link PluginIdentifiers.toVersionedId}. + */ + export function idAndVersionFromVersionedId(probablyId: string): IdAndVersion | undefined { + const endOfPublisher = probablyId.indexOf('.'); + const endOfName = probablyId.indexOf('@', endOfPublisher); + if (endOfPublisher === -1 || endOfName === -1) { + return undefined; + } + return { id: probablyId.slice(0, endOfName) as UnversionedId, version: probablyId.slice(endOfName + 1) }; + } +} diff --git a/packages/plugin-ext/src/common/plugin-protocol.ts b/packages/plugin-ext/src/common/plugin-protocol.ts index 28622bfafdec5..42e58ab5515dc 100644 --- a/packages/plugin-ext/src/common/plugin-protocol.ts +++ b/packages/plugin-ext/src/common/plugin-protocol.ts @@ -25,7 +25,9 @@ import { PreferenceSchema, PreferenceSchemaProperties } from '@theia/core/lib/co import { ProblemMatcherContribution, ProblemPatternContribution, TaskDefinition } from '@theia/task/lib/common'; import { ColorDefinition } from '@theia/core/lib/common/color'; import { ResourceLabelFormatter } from '@theia/core/lib/common/label-protocol'; +import { PluginIdentifiers } from './plugin-identifiers'; +export { PluginIdentifiers }; export const hostedServicePath = '/services/hostedPlugin'; /** @@ -38,7 +40,8 @@ export type PluginEngine = string; */ export interface PluginPackage { name: string; - publisher: string; + // The publisher is not guaranteed to be defined for unpublished plugins. https://github.com/microsoft/vscode-vsce/commit/a38657ece04c20e4fbde15d5ac1ed39ca51cb856 + publisher: string | undefined; version: string; engines: { [type in PluginEngine]: string; @@ -487,6 +490,8 @@ export interface PluginDeployerFileHandlerContext { export interface PluginDeployerDirectoryHandlerContext { + copy(origin: string, target: string): Promise; + pluginEntry(): PluginDeployerEntry; } @@ -811,6 +816,7 @@ export interface PluginMetadata { model: PluginModel; lifecycle: PluginLifecycle; isUnderDevelopment?: boolean; + outOfSync: boolean; } export const MetadataProcessor = Symbol('MetadataProcessor'); @@ -837,6 +843,11 @@ export interface HostedPluginClient { export interface PluginDependencies { metadata: PluginMetadata + /** + * Actual listing of plugin dependencies. + * Mapping from {@link PluginIdentifiers.UnversionedId external representation} of plugin identity to a string + * that can be used to identify the resolver for the specific plugin case, e.g. with scheme `vscode://`. + */ mapping?: Map } @@ -845,14 +856,25 @@ export interface PluginDeployerHandler { deployFrontendPlugins(frontendPlugins: PluginDeployerEntry[]): Promise; deployBackendPlugins(backendPlugins: PluginDeployerEntry[]): Promise; - getDeployedPlugin(pluginId: string): DeployedPlugin | undefined; - undeployPlugin(pluginId: string): Promise; + getDeployedPluginsById(pluginId: string): DeployedPlugin[]; + + getDeployedPlugin(pluginId: PluginIdentifiers.VersionedId): DeployedPlugin | undefined; + /** + * Removes the plugin from the location it originally resided on disk. + * Unless `--uncompressed-plugins-in-place` is passed to the CLI, this operation is safe. + */ + uninstallPlugin(pluginId: PluginIdentifiers.VersionedId): Promise; + /** + * Removes the plugin from the locations to which it had been deployed. + * This operation is not safe - references to deleted assets may remain. + */ + undeployPlugin(pluginId: PluginIdentifiers.VersionedId): Promise; getPluginDependencies(pluginToBeInstalled: PluginDeployerEntry): Promise; } export interface GetDeployedPluginsParams { - pluginIds: string[] + pluginIds: PluginIdentifiers.VersionedId[] } export interface DeployedPlugin { @@ -867,7 +889,9 @@ export interface DeployedPlugin { export const HostedPluginServer = Symbol('HostedPluginServer'); export interface HostedPluginServer extends JsonRpcServer { - getDeployedPluginIds(): Promise; + getDeployedPluginIds(): Promise; + + getUninstalledPluginIds(): Promise; getDeployedPlugins(params: GetDeployedPluginsParams): Promise; @@ -899,8 +923,8 @@ export interface PluginServer { * @param type whether a plugin is installed by a system or a user, defaults to a user */ deploy(pluginEntry: string, type?: PluginType): Promise; - - undeploy(pluginId: string): Promise; + uninstall(pluginId: PluginIdentifiers.VersionedId): Promise; + undeploy(pluginId: PluginIdentifiers.VersionedId): Promise; setStorageValue(key: string, value: KeysToAnyValues, kind: PluginStorageKind): Promise; getStorageValue(key: string, kind: PluginStorageKind): Promise; @@ -925,7 +949,7 @@ export interface ServerPluginRunner { /** * Provides additional plugin ids. */ - getExtraDeployedPluginIds(): Promise; + getExtraDeployedPluginIds(): Promise; } diff --git a/packages/plugin-ext/src/hosted/browser/hosted-plugin.ts b/packages/plugin-ext/src/hosted/browser/hosted-plugin.ts index bd69ccf2ac528..b91d36bcbfd87 100644 --- a/packages/plugin-ext/src/hosted/browser/hosted-plugin.ts +++ b/packages/plugin-ext/src/hosted/browser/hosted-plugin.ts @@ -25,7 +25,7 @@ import debounce = require('@theia/core/shared/lodash.debounce'); import { UUID } from '@theia/core/shared/@phosphor/coreutils'; import { injectable, inject, interfaces, named, postConstruct } from '@theia/core/shared/inversify'; import { PluginWorker } from './plugin-worker'; -import { PluginMetadata, getPluginId, HostedPluginServer, DeployedPlugin, PluginServer } from '../../common/plugin-protocol'; +import { PluginMetadata, getPluginId, HostedPluginServer, DeployedPlugin, PluginServer, PluginIdentifiers } from '../../common/plugin-protocol'; import { HostedPluginWatcher } from './hosted-plugin-watcher'; import { MAIN_RPC_CONTEXT, PluginManagerExt, ConfigStorage, UIKind } from '../../common/plugin-api-rpc'; import { setUpPluginApi } from '../../main/browser/main-context'; @@ -165,7 +165,7 @@ export class HostedPluginSupport { protected readonly managers = new Map(); - private readonly contributions = new Map(); + private readonly contributions = new Map(); protected readonly activationEvents = new Set(); @@ -240,7 +240,7 @@ export class HostedPluginSupport { return plugins; } - getPlugin(id: string): DeployedPlugin | undefined { + getPlugin(id: PluginIdentifiers.UnversionedId): DeployedPlugin | undefined { const contributions = this.contributions.get(id); return contributions && contributions.plugin; } @@ -312,27 +312,33 @@ export class HostedPluginSupport { let syncPluginsMeasurement: Measurement | undefined; const toUnload = new Set(this.contributions.keys()); + let didChangeInstallationStatus = false; try { - const pluginIds: string[] = []; - const deployedPluginIds = await this.server.getDeployedPluginIds(); + const newPluginIds: PluginIdentifiers.VersionedId[] = []; + const [deployedPluginIds, uninstalledPluginIds] = await Promise.all([this.server.getDeployedPluginIds(), this.server.getUninstalledPluginIds()]); waitPluginsMeasurement.log('Waiting for backend deployment'); syncPluginsMeasurement = this.measure('syncPlugins'); - for (const pluginId of deployedPluginIds) { - toUnload.delete(pluginId); - if (!this.contributions.has(pluginId)) { - pluginIds.push(pluginId); + for (const versionedId of deployedPluginIds) { + const unversionedId = PluginIdentifiers.unversionedFromVersioned(versionedId); + toUnload.delete(unversionedId); + if (!this.contributions.has(unversionedId)) { + newPluginIds.push(versionedId); } } for (const pluginId of toUnload) { - const contribution = this.contributions.get(pluginId); - if (contribution) { - contribution.dispose(); + this.contributions.get(pluginId)?.dispose(); + } + for (const versionedId of uninstalledPluginIds) { + const plugin = this.getPlugin(PluginIdentifiers.unversionedFromVersioned(versionedId)); + if (plugin && PluginIdentifiers.componentsToVersionedId(plugin.metadata.model) === versionedId && !plugin.metadata.outOfSync) { + didChangeInstallationStatus = true; + plugin.metadata.outOfSync = didChangeInstallationStatus = true; } } - if (pluginIds.length) { - const plugins = await this.server.getDeployedPlugins({ pluginIds }); + if (newPluginIds.length) { + const plugins = await this.server.getDeployedPlugins({ pluginIds: newPluginIds }); for (const plugin of plugins) { - const pluginId = plugin.metadata.model.id; + const pluginId = PluginIdentifiers.componentsToUnversionedId(plugin.metadata.model); const contributions = new PluginContributions(plugin); this.contributions.set(pluginId, contributions); contributions.push(Disposable.create(() => this.contributions.delete(pluginId))); @@ -340,7 +346,7 @@ export class HostedPluginSupport { } } } finally { - if (initialized || toUnload.size) { + if (initialized || toUnload.size || didChangeInstallationStatus) { this.onDidChangePluginsEmitter.fire(undefined); } diff --git a/packages/plugin-ext/src/hosted/browser/worker/plugin-manifest-loader.ts b/packages/plugin-ext/src/hosted/browser/worker/plugin-manifest-loader.ts index 0b71ee63ae945..2cc05c58fa1a2 100644 --- a/packages/plugin-ext/src/hosted/browser/worker/plugin-manifest-loader.ts +++ b/packages/plugin-ext/src/hosted/browser/worker/plugin-manifest-loader.ts @@ -16,7 +16,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { PluginModel, PluginPackage } from '../../../common/plugin-protocol'; +import { PluginIdentifiers, PluginModel, PluginPackage } from '../../../common/plugin-protocol'; import { Endpoint } from '@theia/core/lib/browser/endpoint'; import URI from '@theia/core/lib/common/uri'; @@ -54,7 +54,9 @@ function readContents(uri: string): Promise { async function readPluginJson(pluginModel: PluginModel, relativePath: string): Promise { const content = await readPluginFile(pluginModel, relativePath); - return JSON.parse(content); + const json = JSON.parse(content) as PluginPackage; + json.publisher ??= PluginIdentifiers.UNPUBLISHED; + return json; } export async function loadManifest(pluginModel: PluginModel): Promise { diff --git a/packages/plugin-ext/src/hosted/node/hosted-plugin-deployer-handler.ts b/packages/plugin-ext/src/hosted/node/hosted-plugin-deployer-handler.ts index 8183cb8784095..3116f29bc0a19 100644 --- a/packages/plugin-ext/src/hosted/node/hosted-plugin-deployer-handler.ts +++ b/packages/plugin-ext/src/hosted/node/hosted-plugin-deployer-handler.ts @@ -17,11 +17,15 @@ import * as fs from '@theia/core/shared/fs-extra'; import { injectable, inject } from '@theia/core/shared/inversify'; import { ILogger } from '@theia/core'; -import { PluginDeployerHandler, PluginDeployerEntry, PluginEntryPoint, DeployedPlugin, PluginDependencies, PluginType } from '../../common/plugin-protocol'; +import { + PluginDeployerHandler, PluginDeployerEntry, PluginEntryPoint, DeployedPlugin, + PluginDependencies, PluginType, PluginIdentifiers +} from '../../common/plugin-protocol'; import { HostedPluginReader } from './plugin-reader'; import { Deferred } from '@theia/core/lib/common/promise-util'; import { HostedPluginLocalizationService } from './hosted-plugin-localization-service'; import { Stopwatch } from '@theia/core/lib/common'; +import { PluginUninstallationManager } from '../../main/node/plugin-uninstallation-manager'; @injectable() export class HostedPluginDeployerHandler implements PluginDeployerHandler { @@ -38,42 +42,56 @@ export class HostedPluginDeployerHandler implements PluginDeployerHandler { @inject(Stopwatch) protected readonly stopwatch: Stopwatch; - private readonly deployedLocations = new Map>(); + @inject(PluginUninstallationManager) + protected readonly uninstallationManager: PluginUninstallationManager; + + private readonly deployedLocations = new Map>(); + protected readonly originalLocations = new Map(); /** * Managed plugin metadata backend entries. */ - private readonly deployedBackendPlugins = new Map(); + private readonly deployedBackendPlugins = new Map(); /** * Managed plugin metadata frontend entries. */ - private readonly deployedFrontendPlugins = new Map(); + private readonly deployedFrontendPlugins = new Map(); private backendPluginsMetadataDeferred = new Deferred(); private frontendPluginsMetadataDeferred = new Deferred(); - async getDeployedFrontendPluginIds(): Promise { + async getDeployedFrontendPluginIds(): Promise { // await first deploy await this.frontendPluginsMetadataDeferred.promise; // fetch the last deployed state - return [...this.deployedFrontendPlugins.keys()]; + return Array.from(this.deployedFrontendPlugins.keys()); } - async getDeployedBackendPluginIds(): Promise { + async getDeployedBackendPluginIds(): Promise { // await first deploy await this.backendPluginsMetadataDeferred.promise; // fetch the last deployed state - return [...this.deployedBackendPlugins.keys()]; + return Array.from(this.deployedBackendPlugins.keys()); } - getDeployedPlugin(pluginId: string): DeployedPlugin | undefined { - const metadata = this.deployedBackendPlugins.get(pluginId); - if (metadata) { - return metadata; - } - return this.deployedFrontendPlugins.get(pluginId); + getDeployedPluginsById(pluginId: string): DeployedPlugin[] { + const matches: DeployedPlugin[] = []; + const handle = (plugins: Iterable): void => { + for (const plugin of plugins) { + if (PluginIdentifiers.componentsToVersionWithId(plugin.metadata.model).version === pluginId) { + matches.push(plugin); + } + } + }; + handle(this.deployedFrontendPlugins.values()); + handle(this.deployedBackendPlugins.values()); + return matches; + } + + getDeployedPlugin(pluginId: PluginIdentifiers.VersionedId): DeployedPlugin | undefined { + return this.deployedBackendPlugins.get(pluginId) ?? this.deployedFrontendPlugins.get(pluginId); } /** @@ -123,6 +141,8 @@ export class HostedPluginDeployerHandler implements PluginDeployerHandler { protected async deployPlugin(entry: PluginDeployerEntry, entryPoint: keyof PluginEntryPoint): Promise { const pluginPath = entry.path(); const deployPlugin = this.stopwatch.start('deployPlugin'); + let id; + let success = true; try { const manifest = await this.reader.readPackage(pluginPath); if (!manifest) { @@ -133,12 +153,15 @@ export class HostedPluginDeployerHandler implements PluginDeployerHandler { const metadata = this.reader.readMetadata(manifest); metadata.isUnderDevelopment = entry.getValue('isUnderDevelopment') ?? false; - const deployedLocations = this.deployedLocations.get(metadata.model.id) || new Set(); + id = PluginIdentifiers.componentsToVersionedId(metadata.model); + + const deployedLocations = this.deployedLocations.get(id) || new Set(); deployedLocations.add(entry.rootPath); - this.deployedLocations.set(metadata.model.id, deployedLocations); + this.deployedLocations.set(id, deployedLocations); + this.originalLocations.set(id, entry.originalPath()); const deployedPlugins = entryPoint === 'backend' ? this.deployedBackendPlugins : this.deployedFrontendPlugins; - if (deployedPlugins.has(metadata.model.id)) { + if (deployedPlugins.has(id)) { deployPlugin.debug(`Skipped ${entryPoint} plugin ${metadata.model.name} already deployed`); return; } @@ -147,14 +170,35 @@ export class HostedPluginDeployerHandler implements PluginDeployerHandler { const deployed: DeployedPlugin = { metadata, type }; deployed.contributes = this.reader.readContribution(manifest); this.localizationService.deployLocalizations(deployed); - deployedPlugins.set(metadata.model.id, deployed); - deployPlugin.log(`Deployed ${entryPoint} plugin "${metadata.model.name}@${metadata.model.version}" from "${metadata.model.entryPoint[entryPoint] || pluginPath}"`); + deployedPlugins.set(id, deployed); + deployPlugin.log(`Deployed ${entryPoint} plugin "${id}" from "${metadata.model.entryPoint[entryPoint] || pluginPath}"`); } catch (e) { + success = false; deployPlugin.error(`Failed to deploy ${entryPoint} plugin from '${pluginPath}' path`, e); + } finally { + if (success && id) { + this.uninstallationManager.markAsInstalled(id); + } + } + } + + async uninstallPlugin(pluginId: PluginIdentifiers.VersionedId): Promise { + try { + const originalPath = this.originalLocations.get(pluginId); + if (!originalPath) { + return false; + } + await fs.remove(originalPath); + this.originalLocations.delete(pluginId); + this.uninstallationManager.markAsUninstalled(pluginId); + return true; + } catch (e) { + console.error('Error uninstalling plugin', e); + return false; } } - async undeployPlugin(pluginId: string): Promise { + async undeployPlugin(pluginId: PluginIdentifiers.VersionedId): Promise { this.deployedBackendPlugins.delete(pluginId); this.deployedFrontendPlugins.delete(pluginId); const deployedLocations = this.deployedLocations.get(pluginId); diff --git a/packages/plugin-ext/src/hosted/node/hosted-plugin-process.ts b/packages/plugin-ext/src/hosted/node/hosted-plugin-process.ts index c6e9e859a5f71..2c0db80da4a72 100644 --- a/packages/plugin-ext/src/hosted/node/hosted-plugin-process.ts +++ b/packages/plugin-ext/src/hosted/node/hosted-plugin-process.ts @@ -18,7 +18,7 @@ import * as cp from 'child_process'; import { injectable, inject, named } from '@theia/core/shared/inversify'; import { ILogger, ConnectionErrorHandler, ContributionProvider, MessageService } from '@theia/core/lib/common'; import { createIpcEnv } from '@theia/core/lib/node/messaging/ipc-protocol'; -import { HostedPluginClient, ServerPluginRunner, PluginHostEnvironmentVariable, DeployedPlugin, PLUGIN_HOST_BACKEND } from '../../common/plugin-protocol'; +import { HostedPluginClient, ServerPluginRunner, PluginHostEnvironmentVariable, DeployedPlugin, PLUGIN_HOST_BACKEND, PluginIdentifiers } from '../../common/plugin-protocol'; import { MessageType } from '../../common/rpc-protocol'; import { HostedPluginCliContribution } from './hosted-plugin-cli-contribution'; import * as psTree from 'ps-tree'; @@ -225,7 +225,7 @@ export class HostedPluginProcess implements ServerPluginRunner { /** * Provides additional plugin ids. */ - public async getExtraDeployedPluginIds(): Promise { + public async getExtraDeployedPluginIds(): Promise { return []; } diff --git a/packages/plugin-ext/src/hosted/node/hosted-plugin.ts b/packages/plugin-ext/src/hosted/node/hosted-plugin.ts index 937f1a0be7d27..119822628bd1e 100644 --- a/packages/plugin-ext/src/hosted/node/hosted-plugin.ts +++ b/packages/plugin-ext/src/hosted/node/hosted-plugin.ts @@ -16,7 +16,7 @@ import { injectable, inject, multiInject, postConstruct, optional } from '@theia/core/shared/inversify'; import { ILogger, ConnectionErrorHandler } from '@theia/core/lib/common'; -import { HostedPluginClient, PluginModel, ServerPluginRunner, DeployedPlugin } from '../../common/plugin-protocol'; +import { HostedPluginClient, PluginModel, ServerPluginRunner, DeployedPlugin, PluginIdentifiers } from '../../common/plugin-protocol'; import { LogPart } from '../../common/types'; import { HostedPluginProcess } from './hosted-plugin-process'; @@ -95,7 +95,7 @@ export class HostedPluginSupport { /** * Provides additional plugin ids. */ - async getExtraDeployedPluginIds(): Promise { + async getExtraDeployedPluginIds(): Promise { return [].concat.apply([], await Promise.all(this.pluginRunners.map(runner => runner.getExtraDeployedPluginIds()))); } diff --git a/packages/plugin-ext/src/hosted/node/metadata-scanner.ts b/packages/plugin-ext/src/hosted/node/metadata-scanner.ts index 0820db4c186c0..62cb4a126157f 100644 --- a/packages/plugin-ext/src/hosted/node/metadata-scanner.ts +++ b/packages/plugin-ext/src/hosted/node/metadata-scanner.ts @@ -14,15 +14,16 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 // ***************************************************************************** -import { injectable, multiInject } from '@theia/core/shared/inversify'; -import { PluginPackage, PluginScanner, PluginMetadata, PLUGIN_HOST_BACKEND } from '../../common/plugin-protocol'; +import { inject, injectable, multiInject } from '@theia/core/shared/inversify'; +import { PluginPackage, PluginScanner, PluginMetadata, PLUGIN_HOST_BACKEND, PluginIdentifiers } from '../../common/plugin-protocol'; +import { PluginUninstallationManager } from '../../main/node/plugin-uninstallation-manager'; @injectable() export class MetadataScanner { private scanners: Map = new Map(); - constructor( // eslint-disable-next-line @typescript-eslint/indent - @multiInject(PluginScanner) scanners: PluginScanner[] - ) { + @inject(PluginUninstallationManager) protected readonly uninstallationManager: PluginUninstallationManager; + + constructor(@multiInject(PluginScanner) scanners: PluginScanner[]) { scanners.forEach((scanner: PluginScanner) => { this.scanners.set(scanner.apiType, scanner); }); @@ -33,7 +34,8 @@ export class MetadataScanner { return { host: PLUGIN_HOST_BACKEND, model: scanner.getModel(plugin), - lifecycle: scanner.getLifecycle(plugin) + lifecycle: scanner.getLifecycle(plugin), + outOfSync: this.uninstallationManager.isUninstalled(PluginIdentifiers.componentsToVersionedId(plugin)), }; } diff --git a/packages/plugin-ext/src/hosted/node/plugin-manifest-loader.ts b/packages/plugin-ext/src/hosted/node/plugin-manifest-loader.ts index 9f2610e194cff..1dd2ea8fcfec9 100644 --- a/packages/plugin-ext/src/hosted/node/plugin-manifest-loader.ts +++ b/packages/plugin-ext/src/hosted/node/plugin-manifest-loader.ts @@ -16,6 +16,7 @@ import * as path from 'path'; import * as fs from '@theia/core/shared/fs-extra'; +import { PluginIdentifiers } from '../../common'; // eslint-disable-next-line @typescript-eslint/no-explicit-any export async function loadManifest(pluginPath: string): Promise { @@ -25,5 +26,6 @@ export async function loadManifest(pluginPath: string): Promise { if (manifest && manifest.name && manifest.name.startsWith(built_prefix)) { manifest.name = manifest.name.substr(built_prefix.length); } + manifest.publisher ??= PluginIdentifiers.UNPUBLISHED; return manifest; } diff --git a/packages/plugin-ext/src/hosted/node/plugin-service.ts b/packages/plugin-ext/src/hosted/node/plugin-service.ts index 8929a9d155de2..fcd24918ed2f6 100644 --- a/packages/plugin-ext/src/hosted/node/plugin-service.ts +++ b/packages/plugin-ext/src/hosted/node/plugin-service.ts @@ -14,13 +14,14 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 // ***************************************************************************** import { injectable, inject, named, postConstruct } from '@theia/core/shared/inversify'; -import { HostedPluginServer, HostedPluginClient, PluginDeployer, GetDeployedPluginsParams, DeployedPlugin } from '../../common/plugin-protocol'; +import { HostedPluginServer, HostedPluginClient, PluginDeployer, GetDeployedPluginsParams, DeployedPlugin, PluginIdentifiers } from '../../common/plugin-protocol'; import { HostedPluginSupport } from './hosted-plugin'; -import { ILogger, Disposable, ContributionProvider } from '@theia/core'; +import { ILogger, Disposable, ContributionProvider, DisposableCollection } from '@theia/core'; import { ExtPluginApiProvider, ExtPluginApi } from '../../common/plugin-ext-api-contribution'; import { HostedPluginDeployerHandler } from './hosted-plugin-deployer-handler'; import { PluginDeployerImpl } from '../../main/node/plugin-deployer-impl'; import { HostedPluginLocalizationService } from './hosted-plugin-localization-service'; +import { PluginUninstallationManager } from '../../main/node/plugin-uninstallation-manager'; @injectable() export class HostedPluginServerImpl implements HostedPluginServer { @@ -40,9 +41,21 @@ export class HostedPluginServerImpl implements HostedPluginServer { @named(Symbol.for(ExtPluginApiProvider)) protected readonly extPluginAPIContributions: ContributionProvider; + @inject(PluginUninstallationManager) protected readonly uninstallationManager: PluginUninstallationManager; + protected client: HostedPluginClient | undefined; + protected toDispose = new DisposableCollection(); + + protected _ignoredPlugins?: Set; + // We ignore any plugins that are marked as uninstalled the first time the frontend requests information about deployed plugins. + protected get ignoredPlugins(): Set { + if (!this._ignoredPlugins) { + this._ignoredPlugins = new Set(this.uninstallationManager.getUninstalledPluginIds()); + } + return this._ignoredPlugins; + } - protected deployedListener: Disposable; + protected readonly pluginVersions = new Map(); constructor( @inject(HostedPluginSupport) private readonly hostedPlugin: HostedPluginSupport) { @@ -50,38 +63,78 @@ export class HostedPluginServerImpl implements HostedPluginServer { @postConstruct() protected init(): void { - this.deployedListener = this.pluginDeployer.onDidDeploy(() => { - if (this.client) { - this.client.onDidDeploy(); - } - }); + this.toDispose.pushAll([ + this.pluginDeployer.onDidDeploy(() => this.client?.onDidDeploy()), + this.uninstallationManager.onDidChangeUninstalledPlugins(currentUninstalled => { + if (this._ignoredPlugins) { + const uninstalled = new Set(currentUninstalled); + for (const previouslyUninstalled of this._ignoredPlugins) { + if (!uninstalled.has(previouslyUninstalled)) { + this._ignoredPlugins.delete(previouslyUninstalled); + } + } + } + this.client?.onDidDeploy(); + }), + Disposable.create(() => this.hostedPlugin.clientClosed()), + ]); } dispose(): void { - this.hostedPlugin.clientClosed(); - this.deployedListener.dispose(); + this.toDispose.dispose(); } + setClient(client: HostedPluginClient): void { this.client = client; this.hostedPlugin.setClient(client); } - async getDeployedPluginIds(): Promise { + async getDeployedPluginIds(): Promise { const backendMetadata = await this.deployerHandler.getDeployedBackendPluginIds(); if (backendMetadata.length > 0) { this.hostedPlugin.runPluginServer(); } - const plugins = new Set(); - for (const pluginId of await this.deployerHandler.getDeployedFrontendPluginIds()) { - plugins.add(pluginId); + const plugins = new Set(); + const addIds = async (identifiers: PluginIdentifiers.VersionedId[]): Promise => { + for (const pluginId of identifiers) { + if (this.isRelevantPlugin(pluginId)) { + plugins.add(pluginId); + } + } + }; + addIds(await this.deployerHandler.getDeployedFrontendPluginIds()); + addIds(backendMetadata); + addIds(await this.hostedPlugin.getExtraDeployedPluginIds()); + return Array.from(plugins); + } + + /** + * Ensures that the plugin was not uninstalled when this session was started + * and that it matches the first version of the given plugin seen by this session. + * + * The deployment system may have multiple versions of the same plugin available, but + * a single session should only ever activate one of them. + */ + protected isRelevantPlugin(identifier: PluginIdentifiers.VersionedId): boolean { + const versionAndId = PluginIdentifiers.idAndVersionFromVersionedId(identifier); + if (!versionAndId) { + return false; } - for (const pluginId of backendMetadata) { - plugins.add(pluginId); + const knownVersion = this.pluginVersions.get(versionAndId.id); + if (knownVersion !== undefined && knownVersion !== versionAndId.version) { + return false; } - for (const pluginId of await this.hostedPlugin.getExtraDeployedPluginIds()) { - plugins.add(pluginId); + if (this.ignoredPlugins.has(identifier)) { + return false; } - return [...plugins.values()]; + if (knownVersion === undefined) { + this.pluginVersions.set(versionAndId.id, versionAndId.version); + } + return true; + } + + getUninstalledPluginIds(): Promise { + return Promise.resolve(this.uninstallationManager.getUninstalledPluginIds()); } async getDeployedPlugins({ pluginIds }: GetDeployedPluginsParams): Promise { @@ -90,16 +143,19 @@ export class HostedPluginServerImpl implements HostedPluginServer { } const plugins: DeployedPlugin[] = []; let extraDeployedPlugins: Map | undefined; - for (const pluginId of pluginIds) { - let plugin = this.deployerHandler.getDeployedPlugin(pluginId); + for (const versionedId of pluginIds) { + if (!this.isRelevantPlugin(versionedId)) { + continue; + } + let plugin = this.deployerHandler.getDeployedPlugin(versionedId); if (!plugin) { if (!extraDeployedPlugins) { extraDeployedPlugins = new Map(); for (const extraDeployedPlugin of await this.hostedPlugin.getExtraDeployedPlugins()) { - extraDeployedPlugins.set(extraDeployedPlugin.metadata.model.id, extraDeployedPlugin); + extraDeployedPlugins.set(PluginIdentifiers.componentsToVersionedId(extraDeployedPlugin.metadata.model), extraDeployedPlugin); } } - plugin = extraDeployedPlugins.get(pluginId); + plugin = extraDeployedPlugins.get(versionedId); } if (plugin) { plugins.push(plugin); diff --git a/packages/plugin-ext/src/hosted/node/scanners/scanner-theia.ts b/packages/plugin-ext/src/hosted/node/scanners/scanner-theia.ts index 55eaa0520916d..add65e68c4941 100644 --- a/packages/plugin-ext/src/hosted/node/scanners/scanner-theia.ts +++ b/packages/plugin-ext/src/hosted/node/scanners/scanner-theia.ts @@ -58,7 +58,8 @@ import { PluginPackageLocalization, Localization, PluginPackageTranslation, - Translation + Translation, + PluginIdentifiers } from '../../../common/plugin-protocol'; import * as fs from 'fs'; import * as path from 'path'; @@ -109,13 +110,14 @@ export class TheiaPluginScanner implements PluginScanner { } getModel(plugin: PluginPackage): PluginModel { + const publisher = plugin.publisher ?? PluginIdentifiers.UNPUBLISHED; const result: PluginModel = { packagePath: plugin.packagePath, packageUri: this.pluginUriFactory.createUri(plugin).toString(), // see id definition: https://github.com/microsoft/vscode/blob/15916055fe0cb9411a5f36119b3b012458fe0a1d/src/vs/platform/extensions/common/extensions.ts#L167-L169 - id: `${plugin.publisher.toLowerCase()}.${plugin.name.toLowerCase()}`, + id: `${publisher.toLowerCase()}.${plugin.name.toLowerCase()}`, name: plugin.name, - publisher: plugin.publisher, + publisher, version: plugin.version, displayName: plugin.displayName, description: plugin.description, diff --git a/packages/plugin-ext/src/main/node/handlers/plugin-theia-directory-handler.ts b/packages/plugin-ext/src/main/node/handlers/plugin-theia-directory-handler.ts index 2ae650e10731c..80ba6bf4ee2b5 100644 --- a/packages/plugin-ext/src/main/node/handlers/plugin-theia-directory-handler.ts +++ b/packages/plugin-ext/src/main/node/handlers/plugin-theia-directory-handler.ts @@ -14,18 +14,24 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 // ***************************************************************************** +import * as path from 'path'; +import * as filenamify from 'filenamify'; +import * as fs from '@theia/core/shared/fs-extra'; +import { inject, injectable } from '@theia/core/shared/inversify'; +import { FileUri } from '@theia/core/lib/node'; import { - PluginDeployerDirectoryHandler, - PluginDeployerEntry, PluginPackage, PluginDeployerDirectoryHandlerContext, - PluginDeployerEntryType + PluginDeployerDirectoryHandler, PluginDeployerEntry, PluginPackage, PluginDeployerDirectoryHandlerContext, PluginDeployerEntryType, PluginType, PluginIdentifiers } from '../../../common/plugin-protocol'; -import { injectable } from '@theia/core/shared/inversify'; -import * as fs from '@theia/core/shared/fs-extra'; -import * as path from 'path'; +import { PluginCliContribution } from '../plugin-cli-contribution'; +import { getTempDir } from '../temp-dir-util'; @injectable() export class PluginTheiaDirectoryHandler implements PluginDeployerDirectoryHandler { + protected readonly deploymentDirectory = FileUri.create(getTempDir('theia-copied')); + + @inject(PluginCliContribution) protected readonly pluginCli: PluginCliContribution; + accept(resolvedPlugin: PluginDeployerEntry): boolean { console.log('PluginTheiaDirectoryHandler: accepting plugin with path', resolvedPlugin.path()); @@ -37,33 +43,26 @@ export class PluginTheiaDirectoryHandler implements PluginDeployerDirectoryHandl // is there a package.json ? const packageJsonPath = path.resolve(resolvedPlugin.path(), 'package.json'); - const existsPackageJson: boolean = fs.existsSync(packageJsonPath); - if (!existsPackageJson) { - return false; - } - - let packageJson: PluginPackage = resolvedPlugin.getValue('package.json'); - if (!packageJson) { - packageJson = fs.readJSONSync(packageJsonPath); - resolvedPlugin.storeValue('package.json', packageJson); - } - if (!packageJson.engines) { - return false; - } - - if (packageJson.engines && packageJson.engines.theiaPlugin) { - return true; - } + try { + let packageJson = resolvedPlugin.getValue('package.json'); + if (!packageJson) { + packageJson = fs.readJSONSync(packageJsonPath); + packageJson.publisher ??= PluginIdentifiers.UNPUBLISHED; + resolvedPlugin.storeValue('package.json', packageJson); + } + if (packageJson?.engines?.theiaPlugin) { + return true; + } + } catch { /* Failed to read file. Fall through. */ } return false; - } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - handle(context: PluginDeployerDirectoryHandlerContext): Promise { + async handle(context: PluginDeployerDirectoryHandlerContext): Promise { + await this.copyDirectory(context); const types: PluginDeployerEntryType[] = []; - const packageJson: PluginPackage = context.pluginEntry().getValue('package.json'); + const packageJson = context.pluginEntry().getValue('package.json'); if (packageJson.theiaPlugin && packageJson.theiaPlugin.backend) { types.push(PluginDeployerEntryType.BACKEND); } @@ -72,6 +71,35 @@ export class PluginTheiaDirectoryHandler implements PluginDeployerDirectoryHandl } context.pluginEntry().accept(...types); - return Promise.resolve(true); + } + + protected async copyDirectory(context: PluginDeployerDirectoryHandlerContext): Promise { + if (this.pluginCli.copyUncompressedPlugins() && context.pluginEntry().type === PluginType.User) { + const entry = context.pluginEntry(); + const id = entry.id(); + const pathToRestore = entry.path(); + const origin = entry.originalPath(); + const targetDir = await this.getExtensionDir(context); + try { + if (fs.existsSync(targetDir) || !entry.path().startsWith(origin)) { + console.log(`[${id}]: already copied.`); + } else { + console.log(`[${id}]: copying to "${targetDir}"`); + await fs.mkdirp(FileUri.fsPath(this.deploymentDirectory)); + await context.copy(origin, targetDir); + entry.updatePath(targetDir); + if (!this.accept(entry)) { + throw new Error('Unable to resolve plugin metadata after copying'); + } + } + } catch (e) { + console.warn(`[${id}]: Error when copying.`, e); + entry.updatePath(pathToRestore); + } + } + } + + protected async getExtensionDir(context: PluginDeployerDirectoryHandlerContext): Promise { + return FileUri.fsPath(this.deploymentDirectory.resolve(filenamify(context.pluginEntry().id(), { replacement: '_' }))); } } diff --git a/packages/plugin-ext/src/main/node/handlers/plugin-theia-file-handler.ts b/packages/plugin-ext/src/main/node/handlers/plugin-theia-file-handler.ts index f6cf33807b500..5d2f9a6d77728 100644 --- a/packages/plugin-ext/src/main/node/handlers/plugin-theia-file-handler.ts +++ b/packages/plugin-ext/src/main/node/handlers/plugin-theia-file-handler.ts @@ -14,6 +14,7 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 // ***************************************************************************** +import * as path from 'path'; import { PluginDeployerFileHandler, PluginDeployerEntry, PluginDeployerFileHandlerContext, PluginType } from '../../../common/plugin-protocol'; import { injectable, inject } from '@theia/core/shared/inversify'; import { getTempDir } from '../temp-dir-util'; @@ -21,6 +22,7 @@ import * as fs from '@theia/core/shared/fs-extra'; import * as filenamify from 'filenamify'; import { FileUri } from '@theia/core/lib/node/file-uri'; import { PluginTheiaEnvironment } from '../../common/plugin-theia-environment'; +import URI from '@theia/core/lib/common/uri'; @injectable() export class PluginTheiaFileHandler implements PluginDeployerFileHandler { @@ -35,6 +37,7 @@ export class PluginTheiaFileHandler implements PluginDeployerFileHandler { } async handle(context: PluginDeployerFileHandlerContext): Promise { + await this.ensureDiscoverability(context); const id = context.pluginEntry().id(); const pluginDir = await this.getPluginDir(context); console.log(`[${id}]: trying to decompress into "${pluginDir}"...`); @@ -48,11 +51,29 @@ export class PluginTheiaFileHandler implements PluginDeployerFileHandler { context.pluginEntry().updatePath(pluginDir); } - protected async getPluginDir(context: PluginDeployerFileHandlerContext): Promise { - let pluginsDirUri = this.systemPluginsDirUri; + /** + * Ensures that a user-installed plugin file is transferred to the user extension folder. + */ + protected async ensureDiscoverability(context: PluginDeployerFileHandlerContext): Promise { if (context.pluginEntry().type === PluginType.User) { - pluginsDirUri = await this.environment.getPluginsDirUri(); + const userExtensionsDir = await this.environment.getPluginsDirUri(); + const currentPath = context.pluginEntry().path(); + if (!userExtensionsDir.isEqualOrParent(new URI(currentPath)) && !userExtensionsDir.isEqualOrParent(new URI(context.pluginEntry().originalPath()))) { + try { + const newPath = FileUri.fsPath(userExtensionsDir.resolve(path.basename(currentPath))); + await fs.mkdirp(FileUri.fsPath(userExtensionsDir)); + await new Promise((resolve, reject) => { + fs.copyFile(currentPath, newPath, error => error ? reject(error) : resolve()); + }); + context.pluginEntry().updatePath(newPath); + } catch (e) { + console.error(`[${context.pluginEntry().id}]: Failed to copy to user directory. Future sessions may not have access to this plugin.`); + } + } } - return FileUri.fsPath(pluginsDirUri.resolve(filenamify(context.pluginEntry().id(), { replacement: '_' }))); + } + + protected async getPluginDir(context: PluginDeployerFileHandlerContext): Promise { + return FileUri.fsPath(this.systemPluginsDirUri.resolve(filenamify(context.pluginEntry().id(), { replacement: '_' }))); } } diff --git a/packages/plugin-ext/src/main/node/plugin-cli-contribution.ts b/packages/plugin-ext/src/main/node/plugin-cli-contribution.ts index 60ddaa327c492..3d22c2662ab59 100644 --- a/packages/plugin-ext/src/main/node/plugin-cli-contribution.ts +++ b/packages/plugin-ext/src/main/node/plugin-cli-contribution.ts @@ -24,6 +24,7 @@ export class PluginCliContribution implements CliContribution { static PLUGINS = 'plugins'; static PLUGIN_MAX_SESSION_LOGS_FOLDERS = 'plugin-max-session-logs-folders'; + static UNCOMPRESSED_PLUGINS_IN_PLACE = 'uncompressed-plugins-in-place'; /** * This is the default value used in VSCode, see: * - https://github.com/Microsoft/vscode/blob/613447d6b3f458ef7fee227e3876303bf5184580/src/vs/code/electron-browser/sharedProcess/contrib/logsDataCleaner.ts#L32 @@ -32,6 +33,7 @@ export class PluginCliContribution implements CliContribution { protected _localDir: string | undefined; protected _maxSessionLogsFolders: number; + protected _keepUncompressedInPlace = false; configure(conf: Argv): void { conf.option(PluginCliContribution.PLUGINS, { @@ -48,6 +50,12 @@ export class PluginCliContribution implements CliContribution { default: PluginCliContribution.DEFAULT_PLUGIN_MAX_SESSION_LOGS_FOLDERS, nargs: 1 }); + + conf.option(PluginCliContribution.UNCOMPRESSED_PLUGINS_IN_PLACE, { + description: 'Whether user plugins that are stored on disk as uncompressed directories should be run in place or copied to temporary folder.', + type: 'boolean', + default: false, + }); } setArguments(args: Arguments): void { @@ -60,6 +68,7 @@ export class PluginCliContribution implements CliContribution { if (maxSessionLogsFoldersArg && Number.isInteger(maxSessionLogsFoldersArg) && maxSessionLogsFoldersArg > 0) { this._maxSessionLogsFolders = maxSessionLogsFoldersArg; } + this._keepUncompressedInPlace = Boolean(args[PluginCliContribution.UNCOMPRESSED_PLUGINS_IN_PLACE]); } localDir(): string | undefined { @@ -70,4 +79,7 @@ export class PluginCliContribution implements CliContribution { return this._maxSessionLogsFolders; } + copyUncompressedPlugins(): boolean { + return !this._keepUncompressedInPlace; + } } diff --git a/packages/plugin-ext/src/main/node/plugin-deployer-directory-handler-context-impl.ts b/packages/plugin-ext/src/main/node/plugin-deployer-directory-handler-context-impl.ts index 0e0f9184c12c8..b7dac41d710fe 100644 --- a/packages/plugin-ext/src/main/node/plugin-deployer-directory-handler-context-impl.ts +++ b/packages/plugin-ext/src/main/node/plugin-deployer-directory-handler-context-impl.ts @@ -14,12 +14,28 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 // ***************************************************************************** +import * as path from 'path'; +import * as fs from '@theia/core/shared/fs-extra'; import { PluginDeployerEntry, PluginDeployerDirectoryHandlerContext } from '../../common/plugin-protocol'; export class PluginDeployerDirectoryHandlerContextImpl implements PluginDeployerDirectoryHandlerContext { - constructor(private readonly pluginDeployerEntry: PluginDeployerEntry) { + constructor(private readonly pluginDeployerEntry: PluginDeployerEntry) { } + async copy(origin: string, target: string): Promise { + const contents = await fs.readdir(origin); + await fs.mkdirp(target); + await Promise.all(contents.map(async item => { + const itemPath = path.resolve(origin, item); + const targetPath = path.resolve(target, item); + const stat = await fs.stat(itemPath); + if (stat.isDirectory()) { + return this.copy(itemPath, targetPath); + } + if (stat.isFile()) { + return new Promise((resolve, reject) => fs.copyFile(itemPath, targetPath, e => e === null ? resolve() : reject(e))); + } + })); } pluginEntry(): PluginDeployerEntry { diff --git a/packages/plugin-ext/src/main/node/plugin-deployer-file-handler-context-impl.ts b/packages/plugin-ext/src/main/node/plugin-deployer-file-handler-context-impl.ts index ca8de01348bfe..97a1b54fa57bb 100644 --- a/packages/plugin-ext/src/main/node/plugin-deployer-file-handler-context-impl.ts +++ b/packages/plugin-ext/src/main/node/plugin-deployer-file-handler-context-impl.ts @@ -25,7 +25,6 @@ export class PluginDeployerFileHandlerContextImpl implements PluginDeployerFileH async unzip(sourcePath: string, destPath: string): Promise { await decompress(sourcePath, destPath); - return Promise.resolve(); } pluginEntry(): PluginDeployerEntry { diff --git a/packages/plugin-ext/src/main/node/plugin-deployer-impl.ts b/packages/plugin-ext/src/main/node/plugin-deployer-impl.ts index a4093c0a9fe19..740e0b8471d6d 100644 --- a/packages/plugin-ext/src/main/node/plugin-deployer-impl.ts +++ b/packages/plugin-ext/src/main/node/plugin-deployer-impl.ts @@ -17,11 +17,12 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { injectable, optional, multiInject, inject, named } from '@theia/core/shared/inversify'; +import * as semver from 'semver'; import { PluginDeployerResolver, PluginDeployerFileHandler, PluginDeployerDirectoryHandler, PluginDeployerEntry, PluginDeployer, PluginDeployerParticipant, PluginDeployerStartContext, PluginDeployerResolverInit, PluginDeployerFileHandlerContext, - PluginDeployerDirectoryHandlerContext, PluginDeployerEntryType, PluginDeployerHandler, PluginType, UnresolvedPluginEntry + PluginDeployerDirectoryHandlerContext, PluginDeployerEntryType, PluginDeployerHandler, PluginType, UnresolvedPluginEntry, PluginIdentifiers } from '../../common/plugin-protocol'; import { PluginDeployerEntryImpl } from './plugin-deployer-entry-impl'; import { @@ -135,7 +136,11 @@ export class PluginDeployerImpl implements PluginDeployer { deployPlugins.log('Deploy plugins list'); } - async undeploy(pluginId: string): Promise { + async uninstall(pluginId: PluginIdentifiers.VersionedId): Promise { + await this.pluginDeployerHandler.uninstallPlugin(pluginId); + } + + async undeploy(pluginId: PluginIdentifiers.VersionedId): Promise { if (await this.pluginDeployerHandler.undeployPlugin(pluginId)) { this.onDidDeployEmitter.fire(); } @@ -163,49 +168,49 @@ export class PluginDeployerImpl implements PluginDeployer { */ async resolvePlugins(plugins: UnresolvedPluginEntry[]): Promise { const visited = new Set(); - const pluginsToDeploy = new Map(); + const hasBeenVisited = (id: string) => visited.has(id) || (visited.add(id), false); + const pluginsToDeploy = new Map(); + const unversionedIdsHandled = new Map(); - let queue: UnresolvedPluginEntry[] = [...plugins]; + const queue: UnresolvedPluginEntry[] = [...plugins]; while (queue.length) { - const dependenciesChunk: Array<{ + const pendingDependencies: Array<{ dependencies: Map type: PluginType }> = []; - const workload: UnresolvedPluginEntry[] = []; - while (queue.length) { - const current = queue.shift()!; - if (visited.has(current.id)) { - continue; - } else { - workload.push(current); - } - visited.add(current.id); - } - queue = []; - await Promise.all(workload.map(async ({ id, type }) => { - if (type === undefined) { - type = PluginType.System; + await Promise.all(queue.map(async entry => { + if (hasBeenVisited(entry.id)) { + return; } + const type = entry.type ?? PluginType.System; try { - const pluginDeployerEntries = await this.resolvePlugin(id, type); - await this.applyFileHandlers(pluginDeployerEntries); - await this.applyDirectoryFileHandlers(pluginDeployerEntries); + const pluginDeployerEntries = await this.resolveAndHandle(entry.id, type); for (const deployerEntry of pluginDeployerEntries) { - const dependencies = await this.pluginDeployerHandler.getPluginDependencies(deployerEntry); - if (dependencies && !pluginsToDeploy.has(dependencies.metadata.model.id)) { - pluginsToDeploy.set(dependencies.metadata.model.id, deployerEntry); - if (dependencies.mapping) { - dependenciesChunk.push({ dependencies: dependencies.mapping, type }); + const pluginData = await this.pluginDeployerHandler.getPluginDependencies(deployerEntry); + const versionedId = pluginData && PluginIdentifiers.componentsToVersionedId(pluginData.metadata.model); + const unversionedId = versionedId && PluginIdentifiers.componentsToUnversionedId(pluginData.metadata.model); + if (unversionedId && !pluginsToDeploy.has(versionedId)) { + pluginsToDeploy.set(versionedId, deployerEntry); + if (pluginData.mapping) { + pendingDependencies.push({ dependencies: pluginData.mapping, type }); + } + const otherVersions = unversionedIdsHandled.get(unversionedId) ?? []; + otherVersions.push(pluginData.metadata.model.version); + if (otherVersions.length === 1) { + unversionedIdsHandled.set(unversionedId, otherVersions); + } else { + this.findBestVersion(unversionedId, otherVersions, pluginsToDeploy); } } } } catch (e) { - console.error(`Failed to resolve plugins from '${id}'`, e); + console.error(`Failed to resolve plugins from '${entry.id}'`, e); } })); - for (const { dependencies, type } of dependenciesChunk) { + queue.length = 0; + for (const { dependencies, type } of pendingDependencies) { for (const [dependency, deployableDependency] of dependencies) { - if (!pluginsToDeploy.has(dependency)) { + if (!unversionedIdsHandled.has(dependency as PluginIdentifiers.UnversionedId)) { queue.push({ id: deployableDependency, type @@ -217,6 +222,46 @@ export class PluginDeployerImpl implements PluginDeployer { return [...pluginsToDeploy.values()]; } + protected async resolveAndHandle(id: string, type: PluginType): Promise { + const entries = await this.resolvePlugin(id, type); + await this.applyFileHandlers(entries); + await this.applyDirectoryFileHandlers(entries); + return entries; + } + + protected findBestVersion(unversionedId: PluginIdentifiers.UnversionedId, versions: string[], knownPlugins: Map): void { + // If left better, return negative. Then best is index 0. + versions.map(version => ({ version, plugin: knownPlugins.get(PluginIdentifiers.idAndVersionToVersionedId({ version, id: unversionedId })) })) + .sort((left, right) => { + const leftPlugin = left.plugin; + const rightPlugin = right.plugin; + if (!leftPlugin && !rightPlugin) { + return 0; + } + if (!rightPlugin) { + return -1; + } + if (!leftPlugin) { + return 1; + } + if (leftPlugin.type === PluginType.System && rightPlugin.type === PluginType.User) { + return -1; + } + if (leftPlugin.type === PluginType.User && rightPlugin.type === PluginType.System) { + return 1; + } + if (semver.gtr(left.version, right.version)) { + return -1; + } + return 1; + }).forEach((versionedEntry, index) => { + if (index !== 0) { + // Mark as not accepted to prevent deployment of all but the winner. + versionedEntry.plugin?.accept(); + } + }); + } + /** * deploy all plugins that have been accepted */ diff --git a/packages/plugin-ext/src/main/node/plugin-ext-backend-module.ts b/packages/plugin-ext/src/main/node/plugin-ext-backend-module.ts index 22e99677fb44e..dfcb84e349c2b 100644 --- a/packages/plugin-ext/src/main/node/plugin-ext-backend-module.ts +++ b/packages/plugin-ext/src/main/node/plugin-ext-backend-module.ts @@ -39,6 +39,7 @@ import { PluginCliContribution } from './plugin-cli-contribution'; import { PluginTheiaEnvironment } from '../common/plugin-theia-environment'; import { PluginTheiaDeployerParticipant } from './plugin-theia-deployer-participant'; import { WebviewBackendSecurityWarnings } from './webview-backend-security-warnings'; +import { PluginUninstallationManager } from './plugin-uninstallation-manager'; export function bindMainBackend(bind: interfaces.Bind): void { bind(PluginApiContribution).toSelf().inSingletonScope(); @@ -50,6 +51,8 @@ export function bindMainBackend(bind: interfaces.Bind): void { bind(PluginDeployerContribution).toSelf().inSingletonScope(); bind(BackendApplicationContribution).toService(PluginDeployerContribution); + bind(PluginUninstallationManager).toSelf().inSingletonScope(); + bind(PluginDeployerResolver).to(LocalDirectoryPluginDeployerResolver).inSingletonScope(); bind(PluginDeployerResolver).to(LocalFilePluginDeployerResolver).inSingletonScope(); bind(PluginDeployerResolver).to(GithubPluginDeployerResolver).inSingletonScope(); diff --git a/packages/plugin-ext/src/main/node/plugin-server-handler.ts b/packages/plugin-ext/src/main/node/plugin-server-handler.ts index 8e2c868292412..ad2802cfeedd6 100644 --- a/packages/plugin-ext/src/main/node/plugin-server-handler.ts +++ b/packages/plugin-ext/src/main/node/plugin-server-handler.ts @@ -18,7 +18,7 @@ import { injectable, inject } from '@theia/core/shared/inversify'; import { CancellationToken } from '@theia/core/lib/common/cancellation'; import { PluginDeployerImpl } from './plugin-deployer-impl'; import { PluginsKeyValueStorage } from './plugins-key-value-storage'; -import { PluginServer, PluginDeployer, PluginStorageKind, PluginType, UnresolvedPluginEntry } from '../../common/plugin-protocol'; +import { PluginServer, PluginDeployer, PluginStorageKind, PluginType, UnresolvedPluginEntry, PluginIdentifiers } from '../../common/plugin-protocol'; import { KeysToAnyValues, KeysToKeysToAnyValue } from '../../common/types'; @injectable() @@ -42,7 +42,11 @@ export class PluginServerHandler implements PluginServer { return this.pluginDeployer.deploy(pluginEntry); } - undeploy(pluginId: string): Promise { + uninstall(pluginId: PluginIdentifiers.VersionedId): Promise { + return this.pluginDeployer.uninstall(pluginId); + } + + undeploy(pluginId: PluginIdentifiers.VersionedId): Promise { return this.pluginDeployer.undeploy(pluginId); } diff --git a/packages/plugin-ext/src/main/node/plugin-uninstallation-manager.ts b/packages/plugin-ext/src/main/node/plugin-uninstallation-manager.ts new file mode 100644 index 0000000000000..1c30d8a88730b --- /dev/null +++ b/packages/plugin-ext/src/main/node/plugin-uninstallation-manager.ts @@ -0,0 +1,60 @@ +// ***************************************************************************** +// Copyright (C) 2022 Ericsson and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { Emitter, Event } from '@theia/core'; +import { injectable } from '@theia/core/shared/inversify'; +import { PluginIdentifiers } from '../../common'; + +@injectable() +export class PluginUninstallationManager { + protected readonly onDidChangeUninstalledPluginsEmitter = new Emitter(); + + get onDidChangeUninstalledPlugins(): Event { + return this.onDidChangeUninstalledPluginsEmitter.event; + } + + protected uninstalledPlugins: PluginIdentifiers.VersionedId[] = []; + + markAsUninstalled(pluginId: PluginIdentifiers.VersionedId): boolean { + if (!this.uninstalledPlugins.includes(pluginId)) { + this.uninstalledPlugins.push(pluginId); + this.onDidChangeUninstalledPluginsEmitter.fire(Object.freeze(this.uninstalledPlugins.slice())); + return true; + } + return false; + } + + markAsInstalled(pluginId: PluginIdentifiers.VersionedId): boolean { + let index: number; + let didChange = false; + while ((index = this.uninstalledPlugins.indexOf(pluginId)) !== -1) { + this.uninstalledPlugins.splice(index, 1); + didChange = true; + } + if (didChange) { + this.onDidChangeUninstalledPluginsEmitter.fire(Object.freeze(this.uninstalledPlugins.slice())); + } + return didChange; + } + + isUninstalled(pluginId: PluginIdentifiers.VersionedId): boolean { + return this.uninstalledPlugins.includes(pluginId); + } + + getUninstalledPluginIds(): readonly PluginIdentifiers.VersionedId[] { + return Object.freeze(this.uninstalledPlugins.slice()); + } +} diff --git a/packages/vsx-registry/src/browser/vsx-extension-editor-manager.ts b/packages/vsx-registry/src/browser/vsx-extension-editor-manager.ts index 8e1bb72db7e87..e39f64f01f951 100644 --- a/packages/vsx-registry/src/browser/vsx-extension-editor-manager.ts +++ b/packages/vsx-registry/src/browser/vsx-extension-editor-manager.ts @@ -18,7 +18,7 @@ import { injectable } from '@theia/core/shared/inversify'; import URI from '@theia/core/lib/common/uri'; import { WidgetOpenHandler } from '@theia/core/lib/browser'; import { VSXExtensionOptions } from './vsx-extension'; -import { VSXExtensionUri } from '../common/vsx-extension-uri'; +import { VSCodeExtensionUri } from '@theia/plugin-ext-vscode/lib/common/plugin-vscode-uri'; import { VSXExtensionEditor } from './vsx-extension-editor'; @injectable() @@ -27,12 +27,12 @@ export class VSXExtensionEditorManager extends WidgetOpenHandler = {}; get uri(): URI { - return VSXExtensionUri.toUri(this.id); + return VSCodeExtensionUri.toUri(this.id); } get id(): string { @@ -131,7 +135,7 @@ export class VSXExtension implements VSXExtensionData, TreeElement { } get plugin(): DeployedPlugin | undefined { - return this.pluginSupport.getPlugin(this.id); + return this.pluginSupport.getPlugin(this.id as PluginIdentifiers.UnversionedId); } get installed(): boolean { @@ -152,6 +156,10 @@ export class VSXExtension implements VSXExtensionData, TreeElement { } } + reloadWindow(): void { + this.windowService.reload(); + } + protected getData(key: K): VSXExtensionData[K] { const plugin = this.plugin; const model = plugin && plugin.metadata.model; @@ -285,7 +293,7 @@ export class VSXExtension implements VSXExtensionData, TreeElement { async install(): Promise { this._busy++; try { - await this.progressService.withProgress(`"Installing '${this.id}' extension...`, 'extensions', () => + await this.progressService.withProgress(nls.localizeByDefault("Installing extension '{0}' v{1}...", this.id, this.version ?? 0), 'extensions', () => this.pluginServer.deploy(this.uri.toString()) ); } finally { @@ -296,9 +304,13 @@ export class VSXExtension implements VSXExtensionData, TreeElement { async uninstall(): Promise { this._busy++; try { - await this.progressService.withProgress(`Uninstalling '${this.id}' extension...`, 'extensions', () => - this.pluginServer.undeploy(this.id) - ); + const { plugin } = this; + if (plugin) { + await this.progressService.withProgress( + nls.localizeByDefault('Uninstalling {0}...', this.id), 'extensions', + () => this.pluginServer.uninstall(PluginIdentifiers.componentsToVersionedId(plugin.metadata.model)) + ); + } } finally { this._busy--; } @@ -380,30 +392,43 @@ export abstract class AbstractVSXExtensionComponent { + event?.stopPropagation(); + this.props.extension.reloadWindow(); + }; + protected readonly manage = (e: React.MouseEvent) => { e.stopPropagation(); this.props.extension.handleContextMenu(e); }; protected renderAction(host?: TreeWidget): React.ReactNode { - const extension = this.props.extension; - const { builtin, busy, installed } = extension; + const { builtin, busy, plugin } = this.props.extension; const isFocused = (host?.model.getFocusedNode() as TreeElementNode)?.element === this.props.extension; const tabIndex = (!host || isFocused) ? 0 : undefined; + const installed = !!plugin; + const outOfSynch = plugin?.metadata.outOfSync; if (builtin) { return
; } if (busy) { if (installed) { - return ; + return ; } - return ; + return ; } if (installed) { - return
-
; + return
+ { + outOfSynch + ? + : + } + +
+
; } - return ; + return ; } } diff --git a/packages/vsx-registry/src/common/vsx-extension-uri.ts b/packages/vsx-registry/src/common/vsx-extension-uri.ts index 2aad09418a8ff..30d66572dc836 100644 --- a/packages/vsx-registry/src/common/vsx-extension-uri.ts +++ b/packages/vsx-registry/src/common/vsx-extension-uri.ts @@ -14,16 +14,7 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 // ***************************************************************************** -import URI from '@theia/core/lib/common/uri'; +import { VSCodeExtensionUri as VSXExtensionUri } from '@theia/plugin-ext-vscode/lib/common/plugin-vscode-uri'; +/** @deprecated since 1.25.0. Import `VSCodeExtensionUri from `plugin-ext-vscode` package instead. */ +export { VSXExtensionUri }; -export namespace VSXExtensionUri { - export function toUri(id: string): URI { - return new URI(`vscode:extension/${id}`); - } - export function toId(uri: URI): string | undefined { - if (uri.scheme === 'vscode' && uri.path.dir.toString() === 'extension') { - return uri.path.base; - } - return undefined; - } -} diff --git a/packages/vsx-registry/src/node/vsx-extension-resolver.ts b/packages/vsx-registry/src/node/vsx-extension-resolver.ts index 8d4e7b42ede83..751a9eddd26fd 100644 --- a/packages/vsx-registry/src/node/vsx-extension-resolver.ts +++ b/packages/vsx-registry/src/node/vsx-extension-resolver.ts @@ -22,7 +22,7 @@ import { v4 as uuidv4 } from 'uuid'; import { injectable, inject } from '@theia/core/shared/inversify'; import URI from '@theia/core/lib/common/uri'; import { PluginDeployerHandler, PluginDeployerResolver, PluginDeployerResolverContext } from '@theia/plugin-ext/lib/common/plugin-protocol'; -import { VSXExtensionUri } from '../common/vsx-extension-uri'; +import { VSCodeExtensionUri } from '@theia/plugin-ext-vscode/lib/common/plugin-vscode-uri'; import { OVSXClientProvider } from '../common/ovsx-client-provider'; import { VSXExtensionRaw } from '@theia/ovsx-client'; import { RequestService } from '@theia/core/shared/@theia/request'; @@ -48,11 +48,11 @@ export class VSXExtensionResolver implements PluginDeployerResolver { } accept(pluginId: string): boolean { - return !!VSXExtensionUri.toId(new URI(pluginId)); + return !!VSCodeExtensionUri.toId(new URI(pluginId)); } async resolve(context: PluginDeployerResolverContext): Promise { - const id = VSXExtensionUri.toId(new URI(context.getOriginId())); + const id = VSCodeExtensionUri.toId(new URI(context.getOriginId())); if (!id) { return; } @@ -86,15 +86,15 @@ export class VSXExtensionResolver implements PluginDeployerResolver { } protected hasSameOrNewerVersion(id: string, extension: VSXExtensionRaw): string | undefined { - const existingPlugin = this.pluginDeployerHandler.getDeployedPlugin(id); - if (existingPlugin) { + const existingPlugins = this.pluginDeployerHandler.getDeployedPluginsById(id); + const sufficientVersion = existingPlugins.find(existingPlugin => { const existingVersion = semver.clean(existingPlugin.metadata.model.version); const desiredVersion = semver.clean(extension.version); if (desiredVersion && existingVersion && semver.gte(existingVersion, desiredVersion)) { return existingVersion; } - } - return undefined; + }); + return sufficientVersion?.metadata.model.version; } protected async download(downloadUrl: string, downloadPath: string): Promise {