Skip to content

Commit

Permalink
Implement safe plugin uninstallation (#11084)
Browse files Browse the repository at this point in the history
Modifies the plugin system to handle uninstallation and the presence of
multiple versions
 - Most references to plugins should include version number
     this is enforced by typing in most cases
 - Uninstalling a plugin on the frontend will not delete the deployed
     files, and the plugin will remain active on the frontend until the
     application is restarted
  • Loading branch information
colin-grant-work authored Jun 15, 2022
1 parent bf905e8 commit bfa1b44
Show file tree
Hide file tree
Showing 32 changed files with 752 additions and 215 deletions.
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,4 +58,5 @@
"editor.rulers": [
180
],
"typescript.preferences.quoteStyle": "single",
}
27 changes: 13 additions & 14 deletions packages/core/src/common/promise-util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,22 +25,21 @@ import { CancellationToken, CancellationError, cancelled } from './cancellation'
export class Deferred<T = void> {
state: 'resolved' | 'rejected' | 'unresolved' = 'unresolved';
resolve: (value: T | PromiseLike<T>) => void;
reject: (err?: any) => void; // eslint-disable-line @typescript-eslint/no-explicit-any
reject: (err?: unknown) => void;

promise = new Promise<T>((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;
}
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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}`);
Expand Down
41 changes: 41 additions & 0 deletions packages/plugin-ext-vscode/src/common/plugin-vscode-uri.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
await this.copyDirectory(context);
context.pluginEntry().accept(PluginDeployerEntryType.BACKEND);
}

protected async copyDirectory(context: PluginDeployerDirectoryHandlerContext): Promise<void> {
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) });
Expand Down Expand Up @@ -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();
Expand All @@ -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<string> {
return FileUri.fsPath(this.deploymentDirectory.resolve(filenamify(context.pluginEntry().id(), { replacement: '_' })));
}
}
26 changes: 23 additions & 3 deletions packages/plugin-ext-vscode/src/node/plugin-vscode-file-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -40,6 +41,7 @@ export class PluginVsCodeFileHandler implements PluginDeployerFileHandler {
}

async handle(context: PluginDeployerFileHandlerContext): Promise<void> {
await this.ensureDiscoverability(context);
const id = context.pluginEntry().id();
const extensionDir = await this.getExtensionDir(context);
console.log(`[${id}]: trying to decompress into "${extensionDir}"...`);
Expand All @@ -54,11 +56,29 @@ export class PluginVsCodeFileHandler implements PluginDeployerFileHandler {
}

protected async getExtensionDir(context: PluginDeployerFileHandlerContext): Promise<string> {
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<void> {
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<void>((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<void> {
Expand Down
8 changes: 4 additions & 4 deletions packages/plugin-ext-vscode/src/node/scanner-vscode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,25 +16,25 @@

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;

@injectable()
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;
}

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:
Expand Down Expand Up @@ -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));
});
}
}
Expand Down
1 change: 1 addition & 0 deletions packages/plugin-ext/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
84 changes: 84 additions & 0 deletions packages/plugin-ext/src/common/plugin-identifiers.ts
Original file line number Diff line number Diff line change
@@ -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 = '<unpublished>';

/**
* @returns a string in the format `<publisher>.<name>`
*/
export function componentsToUnversionedId({ publisher = UNPUBLISHED, name }: Components): UnversionedId {
return `${publisher.toLowerCase()}.${name.toLowerCase()}`;
}
/**
* @returns a string in the format `<publisher>.<name>@<version>`.
*/
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 `<id>@<version>`.
*/
export function idAndVersionToVersionedId({ id, version }: IdAndVersion): VersionedId {
return `${id}@${version}`;
}
/**
* @returns a string in the format `<publisher>.<name>`.
*/
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) };
}
}
Loading

0 comments on commit bfa1b44

Please sign in to comment.