diff --git a/dev-packages/application-manager/src/generator/webpack-generator.ts b/dev-packages/application-manager/src/generator/webpack-generator.ts index fa942815843b9..0eeb0b38dc269 100644 --- a/dev-packages/application-manager/src/generator/webpack-generator.ts +++ b/dev-packages/application-manager/src/generator/webpack-generator.ts @@ -378,6 +378,7 @@ for (const [entryPointName, entryPointPath] of Object.entries({ ${this.ifPackage('@theia/plugin-ext', "'backend-init-theia': '@theia/plugin-ext/lib/hosted/node/scanners/backend-init-theia',")} ${this.ifPackage('@theia/filesystem', "'nsfw-watcher': '@theia/filesystem/lib/node/nsfw-watcher',")} ${this.ifPackage('@theia/plugin-ext-vscode', "'plugin-vscode-init': '@theia/plugin-ext-vscode/lib/node/plugin-vscode-init',")} + ${this.ifPackage('@theia/api-provider-sample', "'gotd-api-init': '@theia/api-provider-sample/lib/plugin/gotd-api-init',")} })) { commonJsLibraries[entryPointName] = { import: require.resolve(entryPointPath), @@ -429,6 +430,8 @@ const config = { 'ipc-bootstrap': require.resolve('@theia/core/lib/node/messaging/ipc-bootstrap'), ${this.ifPackage('@theia/plugin-ext', () => `// VS Code extension support: 'plugin-host': require.resolve('@theia/plugin-ext/lib/hosted/node/plugin-host'),`)} + ${this.ifPackage('@theia/plugin-ext-headless', () => `// Theia Headless Plugin support: + 'plugin-host-headless': require.resolve('@theia/plugin-ext-headless/lib/hosted/node/plugin-host-headless'),`)} ${this.ifPackage('@theia/process', () => `// Make sure the node-pty thread worker can be executed: 'worker/conoutSocketWorker': require.resolve('node-pty/lib/worker/conoutSocketWorker'),`)} ${this.ifPackage('@theia/git', () => `// Ensure the git locator process can the started diff --git a/examples/api-provider-sample/.eslintrc.js b/examples/api-provider-sample/.eslintrc.js new file mode 100644 index 0000000000000..13089943582b6 --- /dev/null +++ b/examples/api-provider-sample/.eslintrc.js @@ -0,0 +1,10 @@ +/** @type {import('eslint').Linter.Config} */ +module.exports = { + extends: [ + '../../configs/build.eslintrc.json' + ], + parserOptions: { + tsconfigRootDir: __dirname, + project: 'tsconfig.json' + } +}; diff --git a/examples/api-provider-sample/README.md b/examples/api-provider-sample/README.md new file mode 100644 index 0000000000000..dff79d5296502 --- /dev/null +++ b/examples/api-provider-sample/README.md @@ -0,0 +1,48 @@ +
+ +
+ +theia-ext-logo + +

ECLIPSE THEIA - API PROVIDER SAMPLE

+ +
+ +
+ +## Description + +The `@theia/api-provider-sample` extension is a programming example showing how to define and provide a custom API object for _plugins_ to use. +The purpose of the extension is to: +- provide developers with realistic coding examples of providing custom API objects +- provide easy-to-use and test examples for features when reviewing pull requests + +The extension is for reference and test purposes only and is not published on `npm` (`private: true`). + +### Greeting of the Day + +The sample defines a `gotd` API that plugins can import and use to obtain tailored messages with which to greet the world, for example in their activation function. + +The source code is laid out in the `src/` tree as follows: + +- `gotd.d.ts` — the TypeScript definition of the `gotd` API object that plugins import to interact with the "Greeting of the Day" service +- `plugin/` — the API initialization script and the implementation of the API objects (`GreetingExt` and similar interfaces). + All code in this directory runs exclusively in the separate plugin-host Node process, isolated from the main Theia process, together with either headless plugins or the backend of VS Code plugins. + The `GreetingExtImpl` and similar classes communicate with the actual API implementation (`GreetingMainImpl` etc.) classes in the main Theia process via RPC +- `node/` — the API classes implementing `GreetingMain` and similar interfaces and the Inversify bindings that register the API provider. + All code in this directory runs in the main Theia Node process + - `common/` — the RPC API Ext/Main interface definitions corresponding to the backend of the `gotd` plugin API + +## Additional Information + +- [Theia - GitHub](https://github.com/eclipse-theia/theia) +- [Theia - Website](https://theia-ide.org/) + +## License + +- [Eclipse Public License 2.0](http://www.eclipse.org/legal/epl-2.0/) +- [一 (Secondary) GNU General Public License, version 2 with the GNU Classpath Exception](https://projects.eclipse.org/license/secondary-gpl-2.0-cp) + +## Trademark +"Theia" is a trademark of the Eclipse Foundation +https://www.eclipse.org/theia diff --git a/examples/api-provider-sample/package.json b/examples/api-provider-sample/package.json new file mode 100644 index 0000000000000..7f3f6c9bbfd50 --- /dev/null +++ b/examples/api-provider-sample/package.json @@ -0,0 +1,42 @@ +{ + "private": true, + "name": "@theia/api-provider-sample", + "version": "1.45.0", + "description": "Theia - Example code to demonstrate Theia API Provider Extensions", + "dependencies": { + "@theia/core": "1.45.0", + "@theia/plugin-ext-headless": "1.45.0", + "@theia/plugin-ext": "1.45.0" + }, + "theiaExtensions": [ + { + "backend": "lib/node/gotd-backend-module" + } + ], + "keywords": [ + "theia-extension" + ], + "license": "EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0", + "repository": { + "type": "git", + "url": "https://github.com/eclipse-theia/theia.git" + }, + "bugs": { + "url": "https://github.com/eclipse-theia/theia/issues" + }, + "homepage": "https://github.com/eclipse-theia/theia", + "files": [ + "lib", + "src" + ], + "types": "src/gotd.d.ts", + "scripts": { + "lint": "theiaext lint", + "build": "theiaext build", + "watch": "theiaext watch", + "clean": "theiaext clean" + }, + "devDependencies": { + "@theia/ext-scripts": "1.45.0" + } +} diff --git a/examples/api-provider-sample/src/common/plugin-api-rpc.ts b/examples/api-provider-sample/src/common/plugin-api-rpc.ts new file mode 100644 index 0000000000000..87f34925ce0c0 --- /dev/null +++ b/examples/api-provider-sample/src/common/plugin-api-rpc.ts @@ -0,0 +1,70 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource 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-only WITH Classpath-exception-2.0 +// ***************************************************************************** +import { createProxyIdentifier } from '@theia/plugin-ext/lib/common/rpc-protocol'; +import type { greeting } from '../gotd'; +import { Event } from '@theia/core'; + +export enum GreetingKind { + DIRECT = 1, + QUIRKY = 2, + SNARKY = 3, +} + +export interface GreeterData { + readonly uuid: string; + greetingKinds: greeting.GreetingKind[]; +}; + +export const GreetingMain = Symbol('GreetingMain'); +export interface GreetingMain { + $getMessage(greeterId: string): Promise; + + $createGreeter(): Promise; + $destroyGreeter(greeterId: GreeterData['uuid']): Promise; + + $updateGreeter(data: GreeterData): void; +} + +export const GreetingExt = Symbol('GreetingExt'); +export interface GreetingExt { + + // + // External protocol + // + + registerGreeter(): Promise; + unregisterGreeter(uuid: string): Promise; + + getMessage(greeterId: string): Promise; + getGreetingKinds(greeterId: string): readonly greeting.GreetingKind[]; + setGreetingKindEnabled(greeterId: string, greetingKind: greeting.GreetingKind, enable: boolean): void; + onGreetingKindsChanged(greeterId: string): Event; + + // + // Internal protocol + // + + $greeterUpdated(data: GreeterData): void; + +} + +export const PLUGIN_RPC_CONTEXT = { + GREETING_MAIN: createProxyIdentifier('GreetingMain'), +}; + +export const MAIN_RPC_CONTEXT = { + GREETING_EXT: createProxyIdentifier('GreetingExt'), +}; diff --git a/examples/api-provider-sample/src/gotd.d.ts b/examples/api-provider-sample/src/gotd.d.ts new file mode 100644 index 0000000000000..52bbc7a469a5e --- /dev/null +++ b/examples/api-provider-sample/src/gotd.d.ts @@ -0,0 +1,49 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource 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-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +// Strictly speaking, the 'greeting' namespace is an unnecessary level of organization +// but it serves to illustrate how API namespaces are implemented in the backend. +export namespace greeting { + export function createGreeter(): Promise; + + export enum GreetingKind { + DIRECT = 1, + QUIRKY = 2, + SNARKY = 3, + } + + export interface Greeter extends Disposable { + greetingKinds: readonly GreetingKind[]; + + getMessage(): Promise; + + setGreetingKind(kind: GreetingKind, enable = true): void; + + onGreetingKindsChanged: Event; + } +} + +export interface Event { + (listener: (e: T) => unknown, thisArg?: unknown): Disposable; +} + +export interface Disposable { + dispose(): void; +} + +namespace Disposable { + export function create(func: () => void): Disposable; +} diff --git a/examples/api-provider-sample/src/node/ext-plugin-gotd-api-provider.ts b/examples/api-provider-sample/src/node/ext-plugin-gotd-api-provider.ts new file mode 100644 index 0000000000000..e16c7d902e879 --- /dev/null +++ b/examples/api-provider-sample/src/node/ext-plugin-gotd-api-provider.ts @@ -0,0 +1,33 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource 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-only WITH Classpath-exception-2.0 +// ***************************************************************************** +import * as path from 'path'; +import { injectable } from '@theia/core/shared/inversify'; +import { ExtPluginApi, ExtPluginApiProvider } from '@theia/plugin-ext-headless'; + +@injectable() +export class ExtPluginGotdApiProvider implements ExtPluginApiProvider { + provideApi(): ExtPluginApi { + // We can support both backend plugins and headless plugins, so we have only one + // entry-point script. Moreover, the application build packages that script in + // the `../backend/` directory from its source `../plugin/` location, alongside + // the scripts for all other plugin API providers. + const universalInitPath = path.join(__dirname, '../backend/gotd-api-init'); + return { + backendInitPath: universalInitPath, + headlessInitPath: universalInitPath + }; + } +} diff --git a/examples/api-provider-sample/src/node/gotd-backend-module.ts b/examples/api-provider-sample/src/node/gotd-backend-module.ts new file mode 100644 index 0000000000000..08b54a7f556f1 --- /dev/null +++ b/examples/api-provider-sample/src/node/gotd-backend-module.ts @@ -0,0 +1,28 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource 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-only WITH Classpath-exception-2.0 +// ***************************************************************************** +import { ContainerModule } from '@theia/core/shared/inversify'; +import { ExtPluginApiProvider } from '@theia/plugin-ext'; +import { ExtPluginGotdApiProvider } from './ext-plugin-gotd-api-provider'; +import { MainPluginApiProvider } from '@theia/plugin-ext/lib/common/plugin-ext-api-contribution'; +import { GotdMainPluginApiProvider } from './gotd-main-plugin-provider'; +import { GreetingMain } from '../common/plugin-api-rpc'; +import { GreetingMainImpl } from './greeting-main-impl'; + +export default new ContainerModule(bind => { + bind(Symbol.for(ExtPluginApiProvider)).to(ExtPluginGotdApiProvider).inSingletonScope(); + bind(MainPluginApiProvider).to(GotdMainPluginApiProvider).inSingletonScope(); + bind(GreetingMain).to(GreetingMainImpl).inSingletonScope(); +}); diff --git a/examples/api-provider-sample/src/node/gotd-main-plugin-provider.ts b/examples/api-provider-sample/src/node/gotd-main-plugin-provider.ts new file mode 100644 index 0000000000000..14778112cb4a9 --- /dev/null +++ b/examples/api-provider-sample/src/node/gotd-main-plugin-provider.ts @@ -0,0 +1,29 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource 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-only WITH Classpath-exception-2.0 +// ***************************************************************************** +import { MainPluginApiProvider } from '@theia/plugin-ext/lib/common/plugin-ext-api-contribution'; +import { RPCProtocol } from '@theia/plugin-ext/lib/common/rpc-protocol'; +import { inject, injectable } from '@theia/core/shared/inversify'; +import { GreetingMain, PLUGIN_RPC_CONTEXT } from '../common/plugin-api-rpc'; + +@injectable() +export class GotdMainPluginApiProvider implements MainPluginApiProvider { + @inject(GreetingMain) + protected readonly greetingMain: GreetingMain; + + initialize(rpc: RPCProtocol): void { + rpc.set(PLUGIN_RPC_CONTEXT.GREETING_MAIN, this.greetingMain); + } +} diff --git a/examples/api-provider-sample/src/node/greeting-main-impl.ts b/examples/api-provider-sample/src/node/greeting-main-impl.ts new file mode 100644 index 0000000000000..02bf2d2eb9f45 --- /dev/null +++ b/examples/api-provider-sample/src/node/greeting-main-impl.ts @@ -0,0 +1,72 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource 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-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { generateUuid } from '@theia/core/lib/common/uuid'; +import { RPCProtocol } from '@theia/plugin-ext/lib/common/rpc-protocol'; +import { inject, injectable } from '@theia/core/shared/inversify'; +import { GreetingKind, GreeterData, GreetingExt, GreetingMain, MAIN_RPC_CONTEXT } from '../common/plugin-api-rpc'; + +const GREETINGS = { + [GreetingKind.DIRECT]: ['Hello, world!', "I'm here!", 'Good day!'], + [GreetingKind.QUIRKY]: ['Howdy doody, world?', "What's crack-a-lackin'?", 'Wazzup werld?'], + [GreetingKind.SNARKY]: ["Oh, it's you, world.", 'You again, world?!', 'Whatever.'], +} as const; + +@injectable() +export class GreetingMainImpl implements GreetingMain { + protected proxy: GreetingExt; + + private greeterData: Record = {}; + + constructor(@inject(RPCProtocol) rpc: RPCProtocol) { + this.proxy = rpc.getProxy(MAIN_RPC_CONTEXT.GREETING_EXT); + } + + async $createGreeter(): Promise { + const result: GreeterData = { + uuid: generateUuid(), + greetingKinds: [GreetingKind.DIRECT] + }; + this.greeterData[result.uuid] = result; + return result; + } + + async $destroyGreeter(greeterId: string): Promise { + delete this.greeterData[greeterId]; + } + + $updateGreeter(data: GreeterData): void { + const myData = this.greeterData[data.uuid]; + if (myData) { + myData.greetingKinds = [...data.greetingKinds]; + this.proxy.$greeterUpdated({ ...myData }); + } + } + + async $getMessage(greeterId: string): Promise { + const data = this.greeterData[greeterId]; + if (data.greetingKinds.length === 0) { + throw new Error(`No greetings are available for greeter ${greeterId}`); + } + + // Get a random one of our supported greeting kinds. + const kind = data.greetingKinds[(Math.floor(Math.random() * data.greetingKinds.length))]; + // And a random greeting of that kind + const greetingIdx = Math.floor(Math.random() * GREETINGS[kind].length); + + return GREETINGS[kind][greetingIdx]; + } +} diff --git a/examples/api-provider-sample/src/plugin/gotd-api-init.ts b/examples/api-provider-sample/src/plugin/gotd-api-init.ts new file mode 100644 index 0000000000000..118410785db71 --- /dev/null +++ b/examples/api-provider-sample/src/plugin/gotd-api-init.ts @@ -0,0 +1,98 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource 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-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { inject, injectable, postConstruct } from '@theia/core/shared/inversify'; +import { RPCProtocol } from '@theia/plugin-ext/lib/common/rpc-protocol'; +import { Plugin } from '@theia/plugin-ext/lib/common/plugin-api-rpc'; +import type * as gotd from '../gotd'; +import { GreetingKind, GreetingExt, MAIN_RPC_CONTEXT } from '../common/plugin-api-rpc'; +import { GreetingExtImpl } from './greeting-ext-impl'; +import { Disposable, DisposableCollection } from '@theia/core'; +import { ApiFactory, PluginContainerModule } from '@theia/plugin-ext/lib/plugin/node/plugin-container-module'; + +// This script is responsible for creating and returning the extension's +// custom API object when a plugin's module imports it. Keep in mind that +// all of the code here runs in the plugin-host node process, whether that +// be the backend host dedicated to some frontend connection or the single +// host for headless plugins, which is where the plugin itself is running. + +type Gotd = typeof gotd; +const GotdApiFactory = Symbol('GotdApiFactory'); +type GotdApiFactory = ApiFactory; + +// Retrieved by Theia to configure the Inversify DI container when the plugin is initialized. +// This is called when the plugin-host process is forked. +export const containerModule = PluginContainerModule.create(({ bind, bindApiFactory }) => { + bind(GreetingExt).to(GreetingExtImpl).inSingletonScope(); + bindApiFactory('@theia/api-provider-sample', GotdApiFactory, GotdApiFactoryImpl); +}); + +// Creates the Greeting of the Day API object +@injectable() +class GotdApiFactoryImpl { + @inject(RPCProtocol) + protected readonly rpc: RPCProtocol; + + @inject(GreetingExt) + protected readonly greetingExt: GreetingExt; + + @postConstruct() + initialize(): void { + this.rpc.set(MAIN_RPC_CONTEXT.GREETING_EXT, this.greetingExt); + } + + createApi(plugin: Plugin): Gotd { + const self = this; + async function createGreeter(): Promise { + const toDispose = new DisposableCollection(); + + const uuid = await self.greetingExt.registerGreeter(); + toDispose.push(Disposable.create(() => self.greetingExt.unregisterGreeter(uuid))); + + const onGreetingKindsChanged = self.greetingExt.onGreetingKindsChanged(uuid); + + const result: gotd.greeting.Greeter = { + get greetingKinds(): readonly GreetingKind[] { + return self.greetingExt.getGreetingKinds(uuid); + }, + + setGreetingKind(greetingKind: GreetingKind, enable = true): void { + self.greetingExt.setGreetingKindEnabled(uuid, greetingKind, enable); + }, + + getMessage(): Promise { + return self.greetingExt.getMessage(uuid); + }, + + onGreetingKindsChanged, + + dispose: toDispose.dispose.bind(toDispose), + }; + + return result; + } + + const greeting: Gotd['greeting'] = { + createGreeter, + GreetingKind + }; + + return { + greeting, + Disposable, + }; + }; +} diff --git a/examples/api-provider-sample/src/plugin/greeting-ext-impl.ts b/examples/api-provider-sample/src/plugin/greeting-ext-impl.ts new file mode 100644 index 0000000000000..56b5dabb35f03 --- /dev/null +++ b/examples/api-provider-sample/src/plugin/greeting-ext-impl.ts @@ -0,0 +1,86 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource 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-only WITH Classpath-exception-2.0 +// ***************************************************************************** +import { inject, injectable } from '@theia/core/shared/inversify'; +import { GreetingKind, GreeterData, GreetingExt, GreetingMain, PLUGIN_RPC_CONTEXT } from '../common/plugin-api-rpc'; +import { RPCProtocol } from '@theia/plugin-ext/lib/common/rpc-protocol'; +import { Event, Emitter } from '@theia/core'; + +type LocalGreeterData = GreeterData & { + onGreetingKindsChangedEmitter: Emitter +}; + +@injectable() +export class GreetingExtImpl implements GreetingExt { + private readonly proxy: GreetingMain; + + private greeterData: Record = {}; + + constructor(@inject(RPCProtocol) rpc: RPCProtocol) { + this.proxy = rpc.getProxy(PLUGIN_RPC_CONTEXT.GREETING_MAIN); + } + + async registerGreeter(): Promise { + const newGreeter = await this.proxy.$createGreeter(); + this.greeterData[newGreeter.uuid] = { + ...newGreeter, + onGreetingKindsChangedEmitter: new Emitter() + }; + return newGreeter.uuid; + } + + unregisterGreeter(uuid: string): Promise { + delete this.greeterData[uuid]; + return this.proxy.$destroyGreeter(uuid); + } + + getGreetingKinds(greeterId: string): readonly GreetingKind[] { + const data = this.greeterData[greeterId]; + return data ? [...data.greetingKinds] : []; + } + + setGreetingKindEnabled(greeterId: string, greetingKind: GreetingKind, enable: boolean): void { + const data = this.greeterData[greeterId]; + + if (data.greetingKinds.includes(greetingKind) === enable) { + return; // Nothing to change + } + + if (enable) { + data.greetingKinds.push(greetingKind); + } else { + const index = data.greetingKinds.indexOf(greetingKind); + data.greetingKinds.splice(index, 1); + } + + this.proxy.$updateGreeter({uuid: greeterId, greetingKinds: [...data.greetingKinds] }); + } + + onGreetingKindsChanged(greeterId: string): Event { + return this.greeterData[greeterId].onGreetingKindsChangedEmitter.event; + } + + getMessage(greeterId: string): Promise { + return this.proxy.$getMessage(greeterId); + } + + $greeterUpdated(data: GreeterData): void { + const myData = this.greeterData[data.uuid]; + if (myData) { + myData.greetingKinds = [...data.greetingKinds]; + myData.onGreetingKindsChangedEmitter.fire([...data.greetingKinds]); + } + } +} diff --git a/examples/api-provider-sample/tsconfig.json b/examples/api-provider-sample/tsconfig.json new file mode 100644 index 0000000000000..a13a4e663a43d --- /dev/null +++ b/examples/api-provider-sample/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../configs/base.tsconfig", + "compilerOptions": { + "composite": true, + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../packages/core" + }, + { + "path": "../../packages/plugin-ext" + }, + { + "path": "../../packages/plugin-ext-headless" + } + ] +} diff --git a/examples/browser/package.json b/examples/browser/package.json index f26ff0b0ff168..125daa00f26b3 100644 --- a/examples/browser/package.json +++ b/examples/browser/package.json @@ -20,7 +20,9 @@ } }, "dependencies": { + "@theia/api-provider-sample": "1.45.0", "@theia/api-samples": "1.45.0", + "@theia/plugin-ext-headless": "1.45.0", "@theia/bulk-edit": "1.45.0", "@theia/callhierarchy": "1.45.0", "@theia/console": "1.45.0", diff --git a/examples/browser/tsconfig.json b/examples/browser/tsconfig.json index e3756e4f8c7de..0debed71dc386 100644 --- a/examples/browser/tsconfig.json +++ b/examples/browser/tsconfig.json @@ -1,6 +1,6 @@ { "extends": "../../configs/base.tsconfig", - "include": [ ], + "include": [], "compilerOptions": { "composite": true }, @@ -80,6 +80,9 @@ { "path": "../../packages/plugin-ext" }, + { + "path": "../../packages/plugin-ext-headless" + }, { "path": "../../packages/plugin-ext-vscode" }, @@ -143,6 +146,9 @@ { "path": "../../packages/workspace" }, + { + "path": "../api-provider-sample" + }, { "path": "../api-samples" } diff --git a/examples/electron/package.json b/examples/electron/package.json index 0f2fe49b360e7..49e3f629d2422 100644 --- a/examples/electron/package.json +++ b/examples/electron/package.json @@ -20,7 +20,9 @@ } }, "dependencies": { + "@theia/api-provider-sample": "1.45.0", "@theia/api-samples": "1.45.0", + "@theia/plugin-ext-headless": "1.45.0", "@theia/bulk-edit": "1.45.0", "@theia/callhierarchy": "1.45.0", "@theia/console": "1.45.0", diff --git a/examples/electron/tsconfig.json b/examples/electron/tsconfig.json index edac6071a7d07..c1c637281a2a3 100644 --- a/examples/electron/tsconfig.json +++ b/examples/electron/tsconfig.json @@ -83,6 +83,9 @@ { "path": "../../packages/plugin-ext" }, + { + "path": "../../packages/plugin-ext-headless" + }, { "path": "../../packages/plugin-ext-vscode" }, @@ -140,6 +143,9 @@ { "path": "../../packages/workspace" }, + { + "path": "../api-provider-sample" + }, { "path": "../api-samples" } diff --git a/packages/plugin-dev/src/node/hosted-plugin-reader.ts b/packages/plugin-dev/src/node/hosted-plugin-reader.ts index 7aeec23cdeb74..d4bcbf87061c0 100644 --- a/packages/plugin-dev/src/node/hosted-plugin-reader.ts +++ b/packages/plugin-dev/src/node/hosted-plugin-reader.ts @@ -42,7 +42,7 @@ export class HostedPluginReader implements BackendApplicationContribution { const hostedPlugin = new PluginDeployerEntryImpl('Hosted Plugin', pluginPath!, pluginPath); hostedPlugin.storeValue('isUnderDevelopment', true); const hostedMetadata = await this.hostedPlugin.promise; - if (hostedMetadata!.model.entryPoint && hostedMetadata!.model.entryPoint.backend) { + if (hostedMetadata!.model.entryPoint && (hostedMetadata!.model.entryPoint.backend || hostedMetadata!.model.entryPoint.headless)) { this.deployerHandler.deployBackendPlugins([hostedPlugin]); } diff --git a/packages/plugin-ext-headless/.eslintrc.js b/packages/plugin-ext-headless/.eslintrc.js new file mode 100644 index 0000000000000..c452b0b44baec --- /dev/null +++ b/packages/plugin-ext-headless/.eslintrc.js @@ -0,0 +1,13 @@ +/** @type {import('eslint').Linter.Config} */ +module.exports = { + extends: [ + '../../configs/build.eslintrc.json' + ], + parserOptions: { + tsconfigRootDir: __dirname, + project: 'tsconfig.json' + }, + rules: { + 'no-null/no-null': 'off', + } +}; diff --git a/packages/plugin-ext-headless/README.md b/packages/plugin-ext-headless/README.md new file mode 100644 index 0000000000000..f77c932c07d09 --- /dev/null +++ b/packages/plugin-ext-headless/README.md @@ -0,0 +1,32 @@ +
+ +
+ +theia-ext-logo + +

ECLIPSE THEIA - HEADLESS PLUGIN-EXT EXTENSION

+ +
+ +
+ +## Description + +The `@theia/plugin-ext-headless` extension contributes functionality for the backend-only "headless `plugin`" API. +The plugin extension host managed by this extension is scoped to the single Theia NodeJS instance. +This is unlike the plugin extension hosts managed by the [`@theia/plugin-ext` extension][plugin-ext] which are scoped on a per-frontend-connection basis. + +[plugin-ext]: ../plugin-ext/README.md + +## Implementation + +The implementation is derived from the [`@theia/plugin-ext` extension][plugin-ext] for frontend-scoped plugins. + +## License + +- [Eclipse Public License 2.0](http://www.eclipse.org/legal/epl-2.0/) +- [一 (Secondary) GNU General Public License, version 2 with the GNU Classpath Exception](https://projects.eclipse.org/license/secondary-gpl-2.0-cp) + +## Trademark +"Theia" is a trademark of the Eclipse Foundation +https://www.eclipse.org/theia diff --git a/packages/plugin-ext-headless/package.json b/packages/plugin-ext-headless/package.json new file mode 100644 index 0000000000000..913519140f197 --- /dev/null +++ b/packages/plugin-ext-headless/package.json @@ -0,0 +1,55 @@ +{ + "name": "@theia/plugin-ext-headless", + "version": "1.45.0", + "description": "Theia - Headless (Backend-only) Plugin Extension", + "main": "lib/common/index.js", + "typings": "lib/common/index.d.ts", + "dependencies": { + "@theia/core": "1.45.0", + "@theia/plugin-ext": "1.45.0", + "@theia/terminal": "1.45.0" + }, + "publishConfig": { + "access": "public" + }, + "theiaExtensions": [ + { + "backend": "lib/plugin-ext-headless-module", + "backendElectron": "lib/plugin-ext-headless-electron-module" + } + ], + "keywords": [ + "theia-extension" + ], + "license": "EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0", + "repository": { + "type": "git", + "url": "https://github.com/eclipse-theia/theia.git" + }, + "bugs": { + "url": "https://github.com/eclipse-theia/theia/issues" + }, + "homepage": "https://github.com/eclipse-theia/theia", + "files": [ + "lib", + "src" + ], + "scripts": { + "build": "theiaext build", + "clean": "theiaext clean", + "compile": "theiaext compile", + "lint": "theiaext lint", + "test": "theiaext test", + "watch": "theiaext watch" + }, + "devDependencies": { + "@theia/ext-scripts": "1.45.0", + "@types/decompress": "^4.2.2", + "@types/escape-html": "^0.0.20", + "@types/lodash.clonedeep": "^4.5.3", + "@types/ps-tree": "^1.1.0" + }, + "nyc": { + "extends": "../../configs/nyc.json" + } +} diff --git a/packages/plugin-ext-headless/src/common/headless-plugin-container.ts b/packages/plugin-ext-headless/src/common/headless-plugin-container.ts new file mode 100644 index 0000000000000..d7b91419a73a4 --- /dev/null +++ b/packages/plugin-ext-headless/src/common/headless-plugin-container.ts @@ -0,0 +1,23 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource 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-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +/** + * Service identifier for Inversify container modules that are used + * to configure the child Container in which scope the services supporting + * the Headless Plugin Host are isolated from connection-scoped frontend/backend + * plugin hosts and the rest of the Theia Node server. + */ +export const HeadlessPluginContainerModule = Symbol('HeadlessPluginContainerModule'); diff --git a/packages/plugin-ext-headless/src/common/headless-plugin-protocol.ts b/packages/plugin-ext-headless/src/common/headless-plugin-protocol.ts new file mode 100644 index 0000000000000..362ca39d87814 --- /dev/null +++ b/packages/plugin-ext-headless/src/common/headless-plugin-protocol.ts @@ -0,0 +1,38 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource 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-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +export * from '@theia/plugin-ext'; + +declare module '@theia/plugin-ext' { + /** + * Extension of the package manifest interface defined by the core plugin framework. + */ + interface PluginPackage { + /** + * Analogues of declarations offered by VS Code plugins, but for the headless instantiation. + */ + headless?: { + /** Activation events supported in headless mode, if any. */ + activationEvents?: string[]; + } + } +} + +/** + * Name for a `string[]` injection binding contributing headless activation event names + * supported by the application. + */ +export const SupportedHeadlessActivationEvents = Symbol('SupportedHeadlessActivationEvents'); diff --git a/packages/plugin-ext-headless/src/common/headless-plugin-rpc.ts b/packages/plugin-ext-headless/src/common/headless-plugin-rpc.ts new file mode 100644 index 0000000000000..8fd578ffd62d7 --- /dev/null +++ b/packages/plugin-ext-headless/src/common/headless-plugin-rpc.ts @@ -0,0 +1,46 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource 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-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { createProxyIdentifier } from '@theia/plugin-ext/lib/common/rpc-protocol'; +import { AbstractPluginManagerExt, EnvInit } from '@theia/plugin-ext'; +import { KeysToKeysToAnyValue } from '@theia/plugin-ext/lib/common/types'; +import { + MAIN_RPC_CONTEXT, PLUGIN_RPC_CONTEXT +} from '@theia/plugin-ext/lib/common/plugin-api-rpc'; +import { ExtPluginApi } from './plugin-ext-headless-api-contribution'; + +export const HEADLESSPLUGIN_RPC_CONTEXT = { + MESSAGE_REGISTRY_MAIN: PLUGIN_RPC_CONTEXT.MESSAGE_REGISTRY_MAIN, + ENV_MAIN: PLUGIN_RPC_CONTEXT.ENV_MAIN, + NOTIFICATION_MAIN: PLUGIN_RPC_CONTEXT.NOTIFICATION_MAIN, + LOCALIZATION_MAIN: PLUGIN_RPC_CONTEXT.LOCALIZATION_MAIN, +}; + +export const HEADLESSMAIN_RPC_CONTEXT = { + HOSTED_PLUGIN_MANAGER_EXT: createProxyIdentifier('HeadlessPluginManagerExt'), + NOTIFICATION_EXT: MAIN_RPC_CONTEXT.NOTIFICATION_EXT, +}; + +export type HeadlessEnvInit = Pick; + +export interface HeadlessPluginManagerInitializeParams { + activationEvents: string[]; + globalState: KeysToKeysToAnyValue; + env: HeadlessEnvInit; + extApi?: ExtPluginApi[]; +} + +export interface HeadlessPluginManagerExt extends AbstractPluginManagerExt { } diff --git a/packages/plugin-ext-headless/src/common/index.ts b/packages/plugin-ext-headless/src/common/index.ts new file mode 100644 index 0000000000000..279c170ea1f20 --- /dev/null +++ b/packages/plugin-ext-headless/src/common/index.ts @@ -0,0 +1,23 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource 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-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +export * from './headless-plugin-container'; +export { + ExtPluginApi, ExtPluginHeadlessApi, ExtPluginApiProvider, + ExtPluginHeadlessApiProvider +} from './plugin-ext-headless-api-contribution'; +export { PluginPackage, SupportedHeadlessActivationEvents } from './headless-plugin-protocol'; +export * from './headless-plugin-rpc'; diff --git a/packages/plugin-ext-headless/src/common/plugin-ext-headless-api-contribution.ts b/packages/plugin-ext-headless/src/common/plugin-ext-headless-api-contribution.ts new file mode 100644 index 0000000000000..fe9726188f6bb --- /dev/null +++ b/packages/plugin-ext-headless/src/common/plugin-ext-headless-api-contribution.ts @@ -0,0 +1,60 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource 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-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { PluginManager } from '@theia/plugin-ext'; +import { RPCProtocol } from '@theia/plugin-ext/lib/common/rpc-protocol'; + +export * from '@theia/plugin-ext'; + +declare module '@theia/plugin-ext' { + /** + * Plugin API extension description. + * This interface describes scripts for all three plugin runtimes: frontend (WebWorker), backend (NodeJs), and headless (NodeJs). + */ + interface ExtPluginApi extends ExtPluginHeadlessApi { + // Note that the frontendInitPath and backendInitPath properties are included by + // Typescript interface merge from the @theia/plugin-ext::ExtPluginApi interface. + } +} + +/** + * Provider for headless extension API description. + */ +export interface ExtPluginHeadlessApiProvider { + /** + * Provide API description. + */ + provideApi(): ExtPluginHeadlessApi; +} + +/** + * Headless Plugin API extension description. + * This interface describes a script for the headless (NodeJs) runtime outside of the scope of frontend connections. + */ +export interface ExtPluginHeadlessApi { + /** + * Path to the script which should be loaded to provide api, module should export `provideApi` function with + * [ExtPluginApiBackendInitializationFn](#ExtPluginApiBackendInitializationFn) signature + */ + headlessInitPath?: string; +} + +/** + * Signature of the extension API initialization function for APIs contributed to headless plugins. + */ +export interface ExtPluginApiHeadlessInitializationFn { + (rpc: RPCProtocol, pluginManager: PluginManager): void; +} diff --git a/packages/plugin-ext-headless/src/hosted/node-electron/plugin-ext-headless-hosted-electron-module.ts b/packages/plugin-ext-headless/src/hosted/node-electron/plugin-ext-headless-hosted-electron-module.ts new file mode 100644 index 0000000000000..64a026d80bf2c --- /dev/null +++ b/packages/plugin-ext-headless/src/hosted/node-electron/plugin-ext-headless-hosted-electron-module.ts @@ -0,0 +1,22 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource 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-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { interfaces } from '@theia/core/shared/inversify'; +import { bindCommonHostedBackend } from '../node/plugin-ext-headless-hosted-module'; + +export function bindElectronBackend(bind: interfaces.Bind): void { + bindCommonHostedBackend(bind); +} diff --git a/packages/plugin-ext-headless/src/hosted/node/headless-hosted-plugin.ts b/packages/plugin-ext-headless/src/hosted/node/headless-hosted-plugin.ts new file mode 100644 index 0000000000000..f33d583ed2403 --- /dev/null +++ b/packages/plugin-ext-headless/src/hosted/node/headless-hosted-plugin.ts @@ -0,0 +1,199 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource 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-only WITH Classpath-exception-2.0 +// ***************************************************************************** +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +// some code copied and modified from https://github.com/microsoft/vscode/blob/da5fb7d5b865aa522abc7e82c10b746834b98639/src/vs/workbench/api/node/extHostExtensionService.ts + +import { generateUuid } from '@theia/core/lib/common/uuid'; +import { injectable, inject, named } from '@theia/core/shared/inversify'; +import { getPluginId, DeployedPlugin, HostedPluginServer, PluginDeployer } from '@theia/plugin-ext/lib/common/plugin-protocol'; +import { setUpPluginApi } from '../../main/node/main-context'; +import { RPCProtocol, RPCProtocolImpl } from '@theia/plugin-ext/lib/common/rpc-protocol'; +import { ContributionProvider, Disposable, DisposableCollection, nls } from '@theia/core'; +import { environment } from '@theia/core/shared/@theia/application-package/lib/environment'; +import { IPCChannel } from '@theia/core/lib/node'; +import { BackendApplicationConfigProvider } from '@theia/core/lib/node/backend-application-config-provider'; +import { HostedPluginProcess } from '@theia/plugin-ext/lib/hosted/node/hosted-plugin-process'; +import { IShellTerminalServer } from '@theia/terminal/lib/common/shell-terminal-protocol'; +import { HeadlessPluginManagerExt, HEADLESSMAIN_RPC_CONTEXT } from '../../common/headless-plugin-rpc'; +import { AbstractHostedPluginSupport, PluginContributions } from '@theia/plugin-ext/lib/hosted/common/hosted-plugin'; +import { TheiaHeadlessPluginScanner } from './scanners/scanner-theia-headless'; +import { SupportedHeadlessActivationEvents } from '../../common/headless-plugin-protocol'; +import { PluginDeployerImpl } from '@theia/plugin-ext/lib/main/node/plugin-deployer-impl'; + +import URI from '@theia/core/lib/common/uri'; +import * as fs from 'fs'; +import * as asyncFs from 'fs/promises'; + +export type HeadlessPluginHost = string; + +export function isHeadlessPlugin(plugin: DeployedPlugin): boolean { + return !!plugin.metadata.model.entryPoint.headless; +} + +@injectable() +export class HeadlessHostedPluginSupport extends AbstractHostedPluginSupport { + + @inject(HostedPluginProcess) + protected readonly pluginProcess: HostedPluginProcess; + + @inject(IShellTerminalServer) + protected readonly shellTerminalServer: IShellTerminalServer; + + @inject(TheiaHeadlessPluginScanner) + protected readonly scanner: TheiaHeadlessPluginScanner; + + @inject(PluginDeployer) + protected readonly pluginDeployer: PluginDeployerImpl; + + @inject(ContributionProvider) + @named(SupportedHeadlessActivationEvents) + protected readonly supportedActivationEventsContributions: ContributionProvider; + + constructor() { + super(generateUuid()); + } + + shutDown(): void { + this.pluginProcess.terminatePluginServer(); + } + + protected createTheiaReadyPromise(): Promise { + return Promise.all([this.envServer.getVariables()]); + } + + // Only load headless plugins + protected acceptPlugin(plugin: DeployedPlugin): boolean | DeployedPlugin { + if (!isHeadlessPlugin(plugin)) { + return false; + } + + if (plugin.metadata.model.engine.type === this.scanner.apiType) { + // Easy case: take it as it is + return true; + } + + // Adapt it for headless + return this.scanner.adaptForHeadless(plugin); + } + + protected handleContributions(_plugin: DeployedPlugin): Disposable { + // We have no contribution points, yet, for headless plugins + return Disposable.NULL; + } + + protected override async beforeSyncPlugins(toDisconnect: DisposableCollection): Promise { + await super.beforeSyncPlugins(toDisconnect); + + // Plugin deployment is asynchronous, so wait until that's finished. + return new Promise((resolve, reject) => { + this.pluginDeployer.onDidDeploy(resolve); + toDisconnect.push(Disposable.create(reject)); + }); + } + + protected async obtainManager(host: string, hostContributions: PluginContributions[], toDisconnect: DisposableCollection): Promise { + let manager = this.managers.get(host); + if (!manager) { + const pluginId = getPluginId(hostContributions[0].plugin.metadata.model); + const rpc = this.initRpc(host, pluginId); + toDisconnect.push(rpc); + + manager = rpc.getProxy(HEADLESSMAIN_RPC_CONTEXT.HOSTED_PLUGIN_MANAGER_EXT); + this.managers.set(host, manager); + toDisconnect.push(Disposable.create(() => this.managers.delete(host))); + + const [extApi, globalState] = await Promise.all([ + this.server.getExtPluginAPI(), + this.pluginServer.getAllStorageValues(undefined) + ]); + if (toDisconnect.disposed) { + return undefined; + } + + const activationEvents = this.supportedActivationEventsContributions.getContributions().flatMap(array => array); + const shell = await this.shellTerminalServer.getDefaultShell(); + const isElectron = environment.electron.is(); + + await manager.$init({ + activationEvents, + globalState, + env: { + language: nls.locale || nls.defaultLocale, + shell, + appName: BackendApplicationConfigProvider.get().applicationName, + appHost: isElectron ? 'desktop' : 'web' // TODO: 'web' could be the embedder's name, e.g. 'github.dev' + }, + extApi + }); + if (toDisconnect.disposed) { + return undefined; + } + } + return manager; + } + + protected initRpc(host: HeadlessPluginHost, pluginId: string): RPCProtocol { + const rpc = this.createServerRpc(host); + this.container.bind(RPCProtocol).toConstantValue(rpc); + setUpPluginApi(rpc, this.container); + this.mainPluginApiProviders.getContributions().forEach(p => p.initialize(rpc, this.container)); + return rpc; + } + + protected createServerRpc(pluginHostId: string): RPCProtocol { + const channel = new IPCChannel(this.pluginProcess['childProcess']); + + return new RPCProtocolImpl(channel); + } + + protected async getStoragePath(): Promise { + // Headless plugins are associated with the main Node process, so + // their storage is the global storage. + return this.getHostGlobalStoragePath(); + } + + protected async getHostGlobalStoragePath(): Promise { + const configDirUri = await this.envServer.getConfigDirUri(); + const globalStorageFolderUri = new URI(configDirUri).resolve('globalStorage'); + const globalStorageFolderUrl = new URL(globalStorageFolderUri.toString()); + + let stat: fs.Stats | undefined; + + try { + stat = await asyncFs.stat(globalStorageFolderUrl); + } catch (_) { + // OK, no such directory + } + + if (stat && !stat.isDirectory()) { + throw new Error(`Global storage folder is not a directory: ${globalStorageFolderUri}`); + } + + // Make sure that folder by the path exists + if (!stat) { + await asyncFs.mkdir(globalStorageFolderUrl, { recursive: true }); + } + + const globalStorageFolderFsPath = await asyncFs.realpath(globalStorageFolderUrl); + if (!globalStorageFolderFsPath) { + throw new Error(`Could not resolve the FS path for URI: ${globalStorageFolderUri}`); + } + return globalStorageFolderFsPath; + } +} diff --git a/packages/plugin-ext-headless/src/hosted/node/plugin-ext-headless-hosted-module.ts b/packages/plugin-ext-headless/src/hosted/node/plugin-ext-headless-hosted-module.ts new file mode 100644 index 0000000000000..51a031ee91d3b --- /dev/null +++ b/packages/plugin-ext-headless/src/hosted/node/plugin-ext-headless-hosted-module.ts @@ -0,0 +1,75 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource 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-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import * as path from 'path'; +import { bindContributionProvider } from '@theia/core/lib/common/contribution-provider'; +import { BackendApplicationContribution } from '@theia/core/lib/node'; +import { ContainerModule, interfaces } from '@theia/core/shared/inversify'; +import { ExtPluginApiProvider, HostedPluginServer, PluginHostEnvironmentVariable, PluginScanner } from '@theia/plugin-ext'; +import { HostedPluginSupport } from '@theia/plugin-ext/lib/hosted/node/hosted-plugin'; +import { HostedPluginProcess, HostedPluginProcessConfiguration } from '@theia/plugin-ext/lib/hosted/node/hosted-plugin-process'; +import { BackendPluginHostableFilter, HostedPluginServerImpl } from '@theia/plugin-ext/lib/hosted/node/plugin-service'; +import { MaybePromise } from '@theia/core'; +import { HeadlessPluginContainerModule } from '../../common/headless-plugin-container'; +import { HeadlessHostedPluginSupport, isHeadlessPlugin } from './headless-hosted-plugin'; +import { TheiaHeadlessPluginScanner } from './scanners/scanner-theia-headless'; +import { SupportedHeadlessActivationEvents } from '../../common/headless-plugin-protocol'; + +export function bindCommonHostedBackend(bind: interfaces.Bind): void { + bind(HostedPluginProcess).toSelf().inSingletonScope(); + bind(HostedPluginSupport).toSelf().inSingletonScope(); + + bindContributionProvider(bind, Symbol.for(ExtPluginApiProvider)); + bindContributionProvider(bind, PluginHostEnvironmentVariable); + bindContributionProvider(bind, SupportedHeadlessActivationEvents); + + bind(HostedPluginServerImpl).toSelf().inSingletonScope(); + bind(HostedPluginServer).toService(HostedPluginServerImpl); + bind(HeadlessHostedPluginSupport).toSelf().inSingletonScope(); + bind(BackendPluginHostableFilter).toConstantValue(isHeadlessPlugin); + + bind(HostedPluginProcessConfiguration).toConstantValue({ + path: path.join(__dirname, 'plugin-host-headless'), + }); +} + +export function bindHeadlessHosted(bind: interfaces.Bind): void { + bind(TheiaHeadlessPluginScanner).toSelf().inSingletonScope(); + bind(PluginScanner).toService(TheiaHeadlessPluginScanner); + bind(SupportedHeadlessActivationEvents).toConstantValue(['*', 'onStartupFinished']); + + bind(BackendApplicationContribution).toDynamicValue(({container}) => { + let hostedPluginSupport: HeadlessHostedPluginSupport | undefined; + + return { + onStart(): MaybePromise { + // Create a child container to isolate the Headless Plugin hosting stack + // from all connection-scoped frontend/backend plugin hosts and + // also to avoid leaking it into the global container scope + const headlessPluginsContainer = container.createChild(); + const modules = container.getAll(HeadlessPluginContainerModule); + headlessPluginsContainer.load(...modules); + + hostedPluginSupport = headlessPluginsContainer.get(HeadlessHostedPluginSupport); + hostedPluginSupport.onStart(headlessPluginsContainer); + }, + + onStop(): void { + hostedPluginSupport?.shutDown(); + } + }; + }); +} diff --git a/packages/plugin-ext-headless/src/hosted/node/plugin-host-headless-module.ts b/packages/plugin-ext-headless/src/hosted/node/plugin-host-headless-module.ts new file mode 100644 index 0000000000000..0237e1662ead9 --- /dev/null +++ b/packages/plugin-ext-headless/src/hosted/node/plugin-host-headless-module.ts @@ -0,0 +1,76 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource 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-only WITH Classpath-exception-2.0 +// ***************************************************************************** +import '@theia/core/shared/reflect-metadata'; +import { ContainerModule } from '@theia/core/shared/inversify'; +import { RPCProtocol, RPCProtocolImpl } from '@theia/plugin-ext/lib/common/rpc-protocol'; +import { AbstractPluginHostRPC, PluginContainerModuleLoader } from '@theia/plugin-ext/lib/hosted/node/plugin-host-rpc'; +import { AbstractPluginManagerExtImpl, MinimalTerminalServiceExt } from '@theia/plugin-ext/lib/plugin/plugin-manager'; +import { HeadlessPluginHostRPC } from './plugin-host-headless-rpc'; +import { HeadlessPluginManagerExtImpl } from '../../plugin/headless-plugin-manager'; +import { IPCChannel } from '@theia/core/lib/node'; +import { InternalPluginContainerModule } from '@theia/plugin-ext/lib/plugin/node/plugin-container-module'; + +import { EnvExtImpl } from '@theia/plugin-ext/lib/plugin/env'; +import { EnvNodeExtImpl } from '@theia/plugin-ext/lib/plugin/node/env-node-ext'; +import { LocalizationExt } from '@theia/plugin-ext'; +import { LocalizationExtImpl } from '@theia/plugin-ext/lib/plugin/localization-ext'; +import { InternalStorageExt } from '@theia/plugin-ext/lib/plugin/plugin-storage'; +import { InternalSecretsExt } from '@theia/plugin-ext/lib/plugin/secrets-ext'; +import { EnvironmentVariableCollectionImpl } from '@theia/plugin-ext/lib/plugin/terminal-ext'; +import { Disposable } from '@theia/core'; + +export default new ContainerModule(bind => { + const channel = new IPCChannel(); + bind(RPCProtocol).toConstantValue(new RPCProtocolImpl(channel)); + + bind(PluginContainerModuleLoader).toDynamicValue(({ container }) => + (module: ContainerModule) => { + container.load(module); + const internalModule = module as InternalPluginContainerModule; + const pluginApiCache = internalModule.initializeApi?.(container); + return pluginApiCache; + }).inSingletonScope(); + + bind(AbstractPluginHostRPC).toService(HeadlessPluginHostRPC); + bind(HeadlessPluginHostRPC).toSelf().inSingletonScope(); + bind(AbstractPluginManagerExtImpl).toService(HeadlessPluginManagerExtImpl); + bind(HeadlessPluginManagerExtImpl).toSelf().inSingletonScope(); + bind(EnvExtImpl).to(EnvNodeExtImpl).inSingletonScope(); + bind(LocalizationExt).to(LocalizationExtImpl).inSingletonScope(); + + const dummySecrets: InternalSecretsExt = { + get: () => Promise.resolve(undefined), + store: () => Promise.resolve(undefined), + delete: () => Promise.resolve(undefined), + $onDidChangePassword: () => Promise.resolve(), + onDidChangePassword: () => Disposable.NULL, + }; + const dummyStorage: InternalStorageExt = { + init: () => undefined, + setPerPluginData: () => Promise.resolve(false), + getPerPluginData: () => ({}), + storageDataChangedEvent: () => Disposable.NULL, + $updatePluginsWorkspaceData: () => undefined + }; + const dummyTerminalService: MinimalTerminalServiceExt = { + $initEnvironmentVariableCollections: () => undefined, + $setShell: () => undefined, + getEnvironmentVariableCollection: () => new EnvironmentVariableCollectionImpl(false), + }; + bind(InternalSecretsExt).toConstantValue(dummySecrets); + bind(InternalStorageExt).toConstantValue(dummyStorage); + bind(MinimalTerminalServiceExt).toConstantValue(dummyTerminalService); +}); diff --git a/packages/plugin-ext-headless/src/hosted/node/plugin-host-headless-rpc.ts b/packages/plugin-ext-headless/src/hosted/node/plugin-host-headless-rpc.ts new file mode 100644 index 0000000000000..175fbebcd8ca9 --- /dev/null +++ b/packages/plugin-ext-headless/src/hosted/node/plugin-host-headless-rpc.ts @@ -0,0 +1,80 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource 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-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { dynamicRequire } from '@theia/core/lib/node/dynamic-require'; +import { ContainerModule, injectable, inject } from '@theia/core/shared/inversify'; +import { EnvExtImpl } from '@theia/plugin-ext/lib/plugin/env'; +import { LocalizationExt } from '@theia/plugin-ext'; +import { LocalizationExtImpl } from '@theia/plugin-ext/lib/plugin/localization-ext'; +import { HEADLESSMAIN_RPC_CONTEXT } from '../../common/headless-plugin-rpc'; +import { HeadlessPluginManagerExtImpl } from '../../plugin/headless-plugin-manager'; +import { AbstractPluginHostRPC, ExtInterfaces } from '@theia/plugin-ext/lib/hosted/node/plugin-host-rpc'; +import { PluginModel } from '@theia/plugin-ext/lib/common/plugin-protocol'; +import { ExtPluginApi, ExtPluginApiHeadlessInitializationFn } from '../../common/plugin-ext-headless-api-contribution'; + +type HeadlessExtInterfaces = Pick; + +/** + * The RPC handler for headless plugins. + */ +@injectable() +export class HeadlessPluginHostRPC extends AbstractPluginHostRPC { + @inject(EnvExtImpl) + protected readonly envExt: EnvExtImpl; + + @inject(LocalizationExt) + protected readonly localizationExt: LocalizationExtImpl; + + constructor() { + super('HEADLESS_PLUGIN_HOST', undefined, + { + $pluginManager: HEADLESSMAIN_RPC_CONTEXT.HOSTED_PLUGIN_MANAGER_EXT, + } + ); + } + + protected createExtInterfaces(): HeadlessExtInterfaces { + return { + envExt: this.envExt, + localizationExt: this.localizationExt + }; + } + + protected createAPIFactory(_extInterfaces: HeadlessExtInterfaces): null { + // As yet there is no default API namespace for backend plugins to access the Theia framework + return null; + } + + protected override getBackendPluginPath(pluginModel: PluginModel): string | undefined { + return pluginModel.entryPoint.headless; + } + + protected initExtApi(extApi: ExtPluginApi): void { + interface PluginExports { + containerModule?: ContainerModule; + provideApi?: ExtPluginApiHeadlessInitializationFn; + } + if (extApi.headlessInitPath) { + const { containerModule, provideApi } = dynamicRequire(extApi.headlessInitPath); + if (containerModule) { + this.loadContainerModule(containerModule); + } + if (provideApi) { + provideApi(this.rpc, this.pluginManager); + } + } + } +} diff --git a/packages/plugin-ext-headless/src/hosted/node/plugin-host-headless.ts b/packages/plugin-ext-headless/src/hosted/node/plugin-host-headless.ts new file mode 100644 index 0000000000000..9d0556db2d635 --- /dev/null +++ b/packages/plugin-ext-headless/src/hosted/node/plugin-host-headless.ts @@ -0,0 +1,111 @@ +// ***************************************************************************** +// Copyright (C) 2018 Red Hat, Inc. 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-only WITH Classpath-exception-2.0 +// ***************************************************************************** +import '@theia/core/shared/reflect-metadata'; +import { Container } from '@theia/core/shared/inversify'; +import { ConnectionClosedError, RPCProtocol } from '@theia/plugin-ext/lib/common/rpc-protocol'; +import { ProcessTerminatedMessage, ProcessTerminateMessage } from '@theia/plugin-ext/lib/hosted/node/hosted-plugin-protocol'; +import { HeadlessPluginHostRPC } from './plugin-host-headless-rpc'; +import pluginHostModule from './plugin-host-headless-module'; + +const banner = `HEADLESS_PLUGIN_HOST(${process.pid}):`; +console.log(banner, 'Starting instance'); + +// override exit() function, to do not allow plugin kill this node +process.exit = function (code?: number): void { + const err = new Error('A plugin called process.exit() but it was blocked.'); + console.warn(banner, err.stack); +} as (code?: number) => never; + +// same for 'crash'(works only in electron) +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const proc = process as any; +if (proc.crash) { + proc.crash = function (): void { + const err = new Error('A plugin called process.crash() but it was blocked.'); + console.warn(banner, err.stack); + }; +} + +process.on('uncaughtException', (err: Error) => { + console.error(banner, err); +}); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const unhandledPromises: Promise[] = []; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +process.on('unhandledRejection', (reason: any, promise: Promise) => { + unhandledPromises.push(promise); + setTimeout(() => { + const index = unhandledPromises.indexOf(promise); + if (index >= 0) { + promise.catch(err => { + unhandledPromises.splice(index, 1); + if (terminating && (ConnectionClosedError.is(err) || ConnectionClosedError.is(reason))) { + // during termination it is expected that pending rpc request are rejected + return; + } + console.error(banner, `Promise rejection not handled in one second: ${err} , reason: ${reason}`); + if (err && err.stack) { + console.error(banner, `With stack trace: ${err.stack}`); + } + }); + } + }, 1000); +}); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +process.on('rejectionHandled', (promise: Promise) => { + const index = unhandledPromises.indexOf(promise); + if (index >= 0) { + unhandledPromises.splice(index, 1); + } +}); + +let terminating = false; + +const container = new Container(); +container.load(pluginHostModule); + +const rpc: RPCProtocol = container.get(RPCProtocol); +const pluginHostRPC = container.get(HeadlessPluginHostRPC); + +process.on('message', async (message: string) => { + if (terminating) { + return; + } + try { + const msg = JSON.parse(message); + if (ProcessTerminateMessage.is(msg)) { + terminating = true; + if (msg.stopTimeout) { + await Promise.race([ + pluginHostRPC.terminate(), + new Promise(resolve => setTimeout(resolve, msg.stopTimeout)) + ]); + } else { + await pluginHostRPC.terminate(); + } + rpc.dispose(); + if (process.send) { + process.send(JSON.stringify({ type: ProcessTerminatedMessage.TYPE })); + } + + } + } catch (e) { + console.error(banner, e); + } +}); diff --git a/packages/plugin-ext-headless/src/hosted/node/scanners/scanner-theia-headless.ts b/packages/plugin-ext-headless/src/hosted/node/scanners/scanner-theia-headless.ts new file mode 100644 index 0000000000000..58fa0f26837e0 --- /dev/null +++ b/packages/plugin-ext-headless/src/hosted/node/scanners/scanner-theia-headless.ts @@ -0,0 +1,85 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource 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-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +/* eslint-disable @theia/localization-check */ + +import { injectable } from '@theia/core/shared/inversify'; +import { DeployedPlugin, PluginPackage, PluginEntryPoint } from '@theia/plugin-ext'; +import { AbstractPluginScanner } from '@theia/plugin-ext/lib/hosted/node/scanners/scanner-theia'; +import { deepClone } from '@theia/core/lib/common/objects'; + +@injectable() +export class TheiaHeadlessPluginScanner extends AbstractPluginScanner { + + constructor() { + super('theiaHeadlessPlugin'); + } + + protected getEntryPoint(plugin: PluginPackage): PluginEntryPoint { + if (plugin?.theiaPlugin?.headless) { + return { + headless: plugin.theiaPlugin.headless + }; + }; + + return { + headless: plugin.main + }; + } + + /** + * Adapt the given `plugin`'s metadata for headless deployment, where it does not + * already natively specify its headless deployment, such as is the case for plugins + * declaring the `"vscode"` or `"theiaPlugin"` engine. This consists of cloning the + * relevant properties of its deployment metadata and modifying them as required, + * including but not limited to: + * + * - renaming the `lifecycle` start and stop functions as 'activate' and 'deactivate' + * following the VS Code naming convention (in case the `plugin` is a Theia-style + * plugin that uses 'start' and 'stop') + * - deleting inapplicable information such as frontend and backend init script paths + * - filtering/rewriting contributions and/or activation events + * + * The cloning is necessary to retain the original information for the non-headless + * deployments that the plugin also supports. + */ + adaptForHeadless(plugin: DeployedPlugin): DeployedPlugin { + return { + type: plugin.type, + metadata: this.adaptMetadataForHeadless(plugin), + contributes: this.adaptContributesForHeadless(plugin) + }; + } + + protected adaptMetadataForHeadless(plugin: DeployedPlugin): DeployedPlugin['metadata'] { + const result = deepClone(plugin.metadata); + + const lifecycle = result.lifecycle; + delete lifecycle.frontendInitPath; + delete lifecycle.backendInitPath; + + // Same as in VS Code + lifecycle.startMethod = 'activate'; + lifecycle.stopMethod = 'deactivate'; + + return result; + } + + protected adaptContributesForHeadless(plugin: DeployedPlugin): DeployedPlugin['contributes'] { + // We don't yet support and contribution points in headless plugins + return undefined; + } +} diff --git a/packages/plugin-ext-headless/src/index.ts b/packages/plugin-ext-headless/src/index.ts new file mode 100644 index 0000000000000..30d6ae1a4b8a8 --- /dev/null +++ b/packages/plugin-ext-headless/src/index.ts @@ -0,0 +1,17 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource 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-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +export * from './common'; diff --git a/packages/plugin-ext-headless/src/main/node/handlers/plugin-theia-headless-directory-handler.ts b/packages/plugin-ext-headless/src/main/node/handlers/plugin-theia-headless-directory-handler.ts new file mode 100644 index 0000000000000..547d80b5a8364 --- /dev/null +++ b/packages/plugin-ext-headless/src/main/node/handlers/plugin-theia-headless-directory-handler.ts @@ -0,0 +1,35 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource 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-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { injectable } from '@theia/core/shared/inversify'; + +import { PluginDeployerDirectoryHandlerContext, PluginDeployerEntryType, PluginPackage } from '@theia/plugin-ext'; +import { AbstractPluginDirectoryHandler } from '@theia/plugin-ext/lib/main/node/handlers/plugin-theia-directory-handler'; + +@injectable() +export class PluginTheiaHeadlessDirectoryHandler extends AbstractPluginDirectoryHandler { + + protected acceptManifest(plugin: PluginPackage): boolean { + return plugin?.engines?.theiaPlugin === undefined && 'theiaHeadlessPlugin' in plugin.engines; + } + + async handle(context: PluginDeployerDirectoryHandlerContext): Promise { + await this.copyDirectory(context); + const types: PluginDeployerEntryType[] = [PluginDeployerEntryType.HEADLESS]; + context.pluginEntry().accept(...types); + } + +} diff --git a/packages/plugin-ext-headless/src/main/node/headless-progress-client.ts b/packages/plugin-ext-headless/src/main/node/headless-progress-client.ts new file mode 100644 index 0000000000000..7822402aed6d3 --- /dev/null +++ b/packages/plugin-ext-headless/src/main/node/headless-progress-client.ts @@ -0,0 +1,44 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource 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-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { injectable } from '@theia/core/shared/inversify'; +import { + CancellationToken, + ProgressClient, ProgressMessage, ProgressUpdate +} from '@theia/core'; + +/** + * A simple progress client for headless plugins that just writes debug messages to the console + * because there is no one connected frontend to which it is appropriate to send the messages. + */ +@injectable() +export class HeadlessProgressClient implements ProgressClient { + async showProgress(_progressId: string, message: ProgressMessage, cancellationToken: CancellationToken): Promise { + if (cancellationToken.isCancellationRequested) { + return ProgressMessage.Cancel; + } + console.debug(message.text); + } + + async reportProgress(_progressId: string, update: ProgressUpdate, message: ProgressMessage, cancellationToken: CancellationToken): Promise { + if (cancellationToken.isCancellationRequested) { + return; + } + const progress = update.work && update.work.total ? `[${100 * Math.min(update.work.done, update.work.total) / update.work.total}%]` : ''; + const text = `${progress} ${update.message ?? 'completed ...'}`; + console.debug(text); + } +} diff --git a/packages/plugin-ext-headless/src/main/node/main-context.ts b/packages/plugin-ext-headless/src/main/node/main-context.ts new file mode 100644 index 0000000000000..2f848d3be5d48 --- /dev/null +++ b/packages/plugin-ext-headless/src/main/node/main-context.ts @@ -0,0 +1,35 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource 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-only WITH Classpath-exception-2.0 +// ***************************************************************************** +import { interfaces } from '@theia/core/shared/inversify'; +import { RPCProtocol } from '@theia/plugin-ext/lib/common/rpc-protocol'; +import { EnvMainImpl } from '@theia/plugin-ext/lib/main/common/env-main'; +import { BasicMessageRegistryMainImpl } from '@theia/plugin-ext/lib/main/common/basic-message-registry-main'; +import { BasicNotificationMainImpl } from '@theia/plugin-ext/lib/main/common/basic-notification-main'; + +import { HEADLESSMAIN_RPC_CONTEXT, HEADLESSPLUGIN_RPC_CONTEXT } from '../../common/headless-plugin-rpc'; + +// This sets up only the minimal plugin API required by the plugin manager to report +// messages and notifications to the main side and to initialize plugins. +export function setUpPluginApi(rpc: RPCProtocol, container: interfaces.Container): void { + const envMain = new EnvMainImpl(rpc, container); + rpc.set(HEADLESSPLUGIN_RPC_CONTEXT.ENV_MAIN, envMain); + + const messageRegistryMain = new BasicMessageRegistryMainImpl(container); + rpc.set(HEADLESSPLUGIN_RPC_CONTEXT.MESSAGE_REGISTRY_MAIN, messageRegistryMain); + + const notificationMain = new BasicNotificationMainImpl(rpc, container, HEADLESSMAIN_RPC_CONTEXT.NOTIFICATION_EXT); + rpc.set(HEADLESSPLUGIN_RPC_CONTEXT.NOTIFICATION_MAIN, notificationMain); +} diff --git a/packages/plugin-ext-headless/src/main/node/plugin-ext-headless-main-module.ts b/packages/plugin-ext-headless/src/main/node/plugin-ext-headless-main-module.ts new file mode 100644 index 0000000000000..19faa51517862 --- /dev/null +++ b/packages/plugin-ext-headless/src/main/node/plugin-ext-headless-main-module.ts @@ -0,0 +1,42 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource 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-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { interfaces } from '@theia/core/shared/inversify'; +import { + MessageClient, MessageService, + ProgressClient, ProgressService, + bindContributionProvider +} from '@theia/core'; +import { MainPluginApiProvider, PluginDeployerDirectoryHandler } from '@theia/plugin-ext'; +import { PluginTheiaHeadlessDirectoryHandler } from './handlers/plugin-theia-headless-directory-handler'; +import { HeadlessProgressClient } from './headless-progress-client'; + +export function bindHeadlessMain(bind: interfaces.Bind): void { + bind(PluginDeployerDirectoryHandler).to(PluginTheiaHeadlessDirectoryHandler).inSingletonScope(); +} + +export function bindBackendMain(bind: interfaces.Bind, unbind: interfaces.Unbind, isBound: interfaces.IsBound, rebind: interfaces.Rebind): void { + bindContributionProvider(bind, MainPluginApiProvider); + + // + // Main API dependencies + // + + bind(MessageService).toSelf().inSingletonScope(); + bind(MessageClient).toSelf().inSingletonScope(); // Just logs to console + bind(ProgressService).toSelf().inSingletonScope(); + bind(ProgressClient).to(HeadlessProgressClient).inSingletonScope(); +} diff --git a/packages/plugin-ext-headless/src/package.spec.ts b/packages/plugin-ext-headless/src/package.spec.ts new file mode 100644 index 0000000000000..b918a55863c16 --- /dev/null +++ b/packages/plugin-ext-headless/src/package.spec.ts @@ -0,0 +1,25 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource 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-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +/* + * This is a placeholder for tests that the extension package should implement + * but as yet does not. + * Please delete this file when a real test is implemented. + */ + +describe('plugin-ext-headless package', () => { + it('placeholder to enable mocha', () => true); +}); diff --git a/packages/plugin-ext-headless/src/plugin-ext-headless-electron-module.ts b/packages/plugin-ext-headless/src/plugin-ext-headless-electron-module.ts new file mode 100644 index 0000000000000..489d884e8cdcf --- /dev/null +++ b/packages/plugin-ext-headless/src/plugin-ext-headless-electron-module.ts @@ -0,0 +1,32 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource 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-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { ContainerModule } from '@theia/core/shared/inversify'; +import { HeadlessPluginContainerModule } from './common/headless-plugin-container'; +import { bindElectronBackend } from './hosted/node-electron/plugin-ext-headless-hosted-electron-module'; +import { bindHeadlessMain, bindBackendMain } from './main/node/plugin-ext-headless-main-module'; +import { bindHeadlessHosted } from './hosted/node/plugin-ext-headless-hosted-module'; + +const backendElectronModule = new ContainerModule((bind, unbind, isBound, rebind) => { + bindBackendMain(bind, unbind, isBound, rebind); + bindElectronBackend(bind); +}); + +export default new ContainerModule(bind => { + bind(HeadlessPluginContainerModule).toConstantValue(backendElectronModule); + bindHeadlessMain(bind); + bindHeadlessHosted(bind); +}); diff --git a/packages/plugin-ext-headless/src/plugin-ext-headless-module.ts b/packages/plugin-ext-headless/src/plugin-ext-headless-module.ts new file mode 100644 index 0000000000000..61c0844f6ae0a --- /dev/null +++ b/packages/plugin-ext-headless/src/plugin-ext-headless-module.ts @@ -0,0 +1,31 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource 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-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { ContainerModule } from '@theia/core/shared/inversify'; +import { HeadlessPluginContainerModule } from './common/headless-plugin-container'; +import { bindHeadlessHosted, bindCommonHostedBackend } from './hosted/node/plugin-ext-headless-hosted-module'; +import { bindHeadlessMain, bindBackendMain } from './main/node/plugin-ext-headless-main-module'; + +const backendModule = new ContainerModule((bind, unbind, isBound, rebind) => { + bindBackendMain(bind, unbind, isBound, rebind); + bindCommonHostedBackend(bind); +}); + +export default new ContainerModule(bind => { + bind(HeadlessPluginContainerModule).toConstantValue(backendModule); + bindHeadlessMain(bind); + bindHeadlessHosted(bind); +}); diff --git a/packages/plugin-ext-headless/src/plugin/headless-plugin-manager.ts b/packages/plugin-ext-headless/src/plugin/headless-plugin-manager.ts new file mode 100644 index 0000000000000..5f2872f4e9168 --- /dev/null +++ b/packages/plugin-ext-headless/src/plugin/headless-plugin-manager.ts @@ -0,0 +1,50 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource 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-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { injectable } from '@theia/core/shared/inversify'; +import { AbstractPluginManagerExtImpl } from '@theia/plugin-ext/lib/plugin/plugin-manager'; +import { HeadlessPluginManagerExt, HeadlessPluginManagerInitializeParams } from '../common/headless-plugin-rpc'; +import { Plugin } from '@theia/plugin-ext'; + +@injectable() +export class HeadlessPluginManagerExtImpl extends AbstractPluginManagerExtImpl implements HeadlessPluginManagerExt { + + private readonly supportedActivationEvents = new Set(); + + async $init(params: HeadlessPluginManagerInitializeParams): Promise { + params.activationEvents?.forEach(event => this.supportedActivationEvents.add(event)); + + this.storage.init(params.globalState, {}); + + this.envExt.setLanguage(params.env.language); + this.envExt.setApplicationName(params.env.appName); + this.envExt.setAppHost(params.env.appHost); + + if (params.extApi) { + this.host.initExtApi(params.extApi); + } + } + + protected override getActivationEvents(plugin: Plugin): string[] | undefined { + const result = plugin.rawModel?.headless?.activationEvents; + return Array.isArray(result) ? result : undefined; + } + + protected isSupportedActivationEvent(activationEvent: string): boolean { + return this.supportedActivationEvents.has(activationEvent.split(':')[0]); + } + +} diff --git a/packages/plugin-ext-headless/tsconfig.json b/packages/plugin-ext-headless/tsconfig.json new file mode 100644 index 0000000000000..e61a769a6936a --- /dev/null +++ b/packages/plugin-ext-headless/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../configs/base.tsconfig", + "compilerOptions": { + "composite": true, + "rootDir": "src", + "outDir": "lib", + "lib": [ + "es6", + "dom", + "webworker" + ] + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../core" + }, + { + "path": "../plugin-ext" + }, + { + "path": "../terminal" + } + ] +} diff --git a/packages/plugin-ext-vscode/src/node/scanner-vscode.ts b/packages/plugin-ext-vscode/src/node/scanner-vscode.ts index 7b1049eea1e5d..cc49cc7f6bf22 100644 --- a/packages/plugin-ext-vscode/src/node/scanner-vscode.ts +++ b/packages/plugin-ext-vscode/src/node/scanner-vscode.ts @@ -51,6 +51,10 @@ export class VsCodePluginScanner extends TheiaPluginScanner implements PluginSca // Default to using backend entryPoint.backend = plugin.main; } + if (plugin.theiaPlugin?.headless) { + // Support the Theia-specific extension for headless plugins + entryPoint.headless = plugin.theiaPlugin?.headless; + } const result: PluginModel = { packagePath: plugin.packagePath, diff --git a/packages/plugin-ext/src/common/plugin-api-rpc.ts b/packages/plugin-ext/src/common/plugin-api-rpc.ts index c0616d5177df6..32140ec1c0625 100644 --- a/packages/plugin-ext/src/common/plugin-api-rpc.ts +++ b/packages/plugin-ext/src/common/plugin-api-rpc.ts @@ -172,6 +172,7 @@ export interface PluginAPI { } +export const PluginManager = Symbol.for('PluginManager'); export interface PluginManager { getAllPlugins(): Plugin[]; getPluginById(pluginId: string): Plugin | undefined; @@ -243,10 +244,9 @@ export interface PluginManagerStartParams { activationEvents: string[] } -export interface PluginManagerExt { - +export interface AbstractPluginManagerExt

> { /** initialize the manager, should be called only once */ - $init(params: PluginManagerInitializeParams): Promise; + $init(params: P): Promise; /** load and activate plugins */ $start(params: PluginManagerStartParams): Promise; @@ -264,6 +264,8 @@ export interface PluginManagerExt { $activatePlugin(id: string): Promise; } +export interface PluginManagerExt extends AbstractPluginManagerExt { } + export interface CommandRegistryMain { $registerCommand(command: theia.CommandDescription): void; $unregisterCommand(id: string): void; @@ -2651,6 +2653,7 @@ export interface IdentifiableInlineCompletion extends InlineCompletion { idx: number; } +export const LocalizationExt = Symbol('LocalizationExt'); export interface LocalizationExt { translateMessage(pluginId: string, details: StringDetails): string; getBundle(pluginId: string): Record | undefined; diff --git a/packages/plugin-ext/src/common/plugin-ext-api-contribution.ts b/packages/plugin-ext/src/common/plugin-ext-api-contribution.ts index 41455e26e98b3..522987b7ca89b 100644 --- a/packages/plugin-ext/src/common/plugin-ext-api-contribution.ts +++ b/packages/plugin-ext/src/common/plugin-ext-api-contribution.ts @@ -19,26 +19,53 @@ import { interfaces } from '@theia/core/shared/inversify'; export const ExtPluginApiProvider = 'extPluginApi'; /** - * Provider for extension API description + * Provider for extension API description. */ export interface ExtPluginApiProvider { /** - * Provide API description + * Provide API description. */ provideApi(): ExtPluginApi; } /** - * Plugin API extension description. - * This interface describes scripts for both plugin runtimes: frontend(WebWorker) and backend(NodeJs) + * Provider for backend extension API description. */ -export interface ExtPluginApi { +export interface ExtPluginBackendApiProvider { + /** + * Provide API description. + */ + provideApi(): ExtPluginBackendApi; +} + +/** + * Provider for frontend extension API description. + */ +export interface ExtPluginFrontendApiProvider { + /** + * Provide API description. + */ + provideApi(): ExtPluginFrontendApi; +} + +/** + * Backend Plugin API extension description. + * This interface describes a script for the backend(NodeJs) runtime. + */ +export interface ExtPluginBackendApi { /** * Path to the script which should be loaded to provide api, module should export `provideApi` function with * [ExtPluginApiBackendInitializationFn](#ExtPluginApiBackendInitializationFn) signature */ backendInitPath?: string; +} + +/** + * Frontend Plugin API extension description. + * This interface describes a script for the frontend(WebWorker) runtime. + */ +export interface ExtPluginFrontendApi { /** * Initialization information for frontend part of Plugin API @@ -46,6 +73,12 @@ export interface ExtPluginApi { frontendExtApi?: FrontendExtPluginApi; } +/** + * Plugin API extension description. + * This interface describes scripts for both plugin runtimes: frontend(WebWorker) and backend(NodeJs) + */ +export interface ExtPluginApi extends ExtPluginBackendApi, ExtPluginFrontendApi { } + export interface ExtPluginApiFrontendInitializationFn { (rpc: RPCProtocol, plugins: Map): void; } diff --git a/packages/plugin-ext/src/common/plugin-protocol.ts b/packages/plugin-ext/src/common/plugin-protocol.ts index 19f6e0d7920f4..547074215bd60 100644 --- a/packages/plugin-ext/src/common/plugin-protocol.ts +++ b/packages/plugin-ext/src/common/plugin-protocol.ts @@ -31,7 +31,7 @@ export { PluginIdentifiers }; export const hostedServicePath = '/services/hostedPlugin'; /** - * Plugin engine (API) type, i.e. 'theiaPlugin', 'vscode', etc. + * Plugin engine (API) type, i.e. 'theiaPlugin', 'vscode', 'theiaHeadlessPlugin', etc. */ export type PluginEngine = string; @@ -49,6 +49,8 @@ export interface PluginPackage { theiaPlugin?: { frontend?: string; backend?: string; + /* Requires the `@theia/plugin-ext-headless` extension. */ + headless?: string; }; main?: string; browser?: string; @@ -445,7 +447,9 @@ export enum PluginDeployerEntryType { FRONTEND, - BACKEND + BACKEND, + + HEADLESS // Deployed in the Theia Node server outside the context of a frontend/backend connection } /** @@ -571,6 +575,7 @@ export interface PluginModel { export interface PluginEntryPoint { frontend?: string; backend?: string; + headless?: string; } /** diff --git a/packages/plugin-ext/src/common/rpc-protocol.ts b/packages/plugin-ext/src/common/rpc-protocol.ts index 6f2a8792437b8..fb8ae1c3ca28a 100644 --- a/packages/plugin-ext/src/common/rpc-protocol.ts +++ b/packages/plugin-ext/src/common/rpc-protocol.ts @@ -39,7 +39,7 @@ export interface MessageConnection { onMessage: Event; } -export const RPCProtocol = Symbol('RPCProtocol'); +export const RPCProtocol = Symbol.for('RPCProtocol'); export interface RPCProtocol extends Disposable { /** * Returns a proxy to an object addressable/named in the plugin process or in the main process. diff --git a/packages/plugin-ext/src/hosted/browser/hosted-plugin.ts b/packages/plugin-ext/src/hosted/browser/hosted-plugin.ts index fc454e5a58966..d9e957a986af5 100644 --- a/packages/plugin-ext/src/hosted/browser/hosted-plugin.ts +++ b/packages/plugin-ext/src/hosted/browser/hosted-plugin.ts @@ -21,26 +21,24 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -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 { generateUuid } from '@theia/core/lib/common/uuid'; +import { injectable, inject, postConstruct } from '@theia/core/shared/inversify'; import { PluginWorker } from './plugin-worker'; -import { PluginMetadata, getPluginId, HostedPluginServer, DeployedPlugin, PluginServer, PluginIdentifiers } from '../../common/plugin-protocol'; +import { getPluginId, DeployedPlugin, HostedPluginServer } from '../../common/plugin-protocol'; import { HostedPluginWatcher } from './hosted-plugin-watcher'; -import { MAIN_RPC_CONTEXT, PluginManagerExt, ConfigStorage, UIKind } from '../../common/plugin-api-rpc'; +import { MAIN_RPC_CONTEXT, PluginManagerExt, UIKind } from '../../common/plugin-api-rpc'; import { setUpPluginApi } from '../../main/browser/main-context'; import { RPCProtocol, RPCProtocolImpl } from '../../common/rpc-protocol'; import { - Disposable, DisposableCollection, Emitter, isCancelled, - ILogger, ContributionProvider, CommandRegistry, WillExecuteCommandEvent, - CancellationTokenSource, RpcProxy, ProgressService, nls + Disposable, DisposableCollection, isCancelled, + CommandRegistry, WillExecuteCommandEvent, + CancellationTokenSource, ProgressService, nls, + RpcProxy } from '@theia/core'; import { PreferenceServiceImpl, PreferenceProviderProvider } from '@theia/core/lib/browser/preferences'; import { WorkspaceService } from '@theia/workspace/lib/browser'; import { PluginContributionHandler } from '../../main/browser/plugin-contribution-handler'; import { getQueryParameters } from '../../main/browser/env-main'; -import { MainPluginApiProvider } from '../../common/plugin-ext-api-contribution'; -import { PluginPathsService } from '../../main/common/plugin-paths-protocol'; import { getPreferences } from '../../main/browser/preference-registry-main'; import { Deferred } from '@theia/core/lib/common/promise-util'; import { DebugSessionManager } from '@theia/debug/lib/browser/debug-session-manager'; @@ -55,7 +53,6 @@ import { WebviewEnvironment } from '../../main/browser/webview/webview-environme import { WebviewWidget } from '../../main/browser/webview/webview'; import { WidgetManager } from '@theia/core/lib/browser/widget-manager'; import { TerminalService } from '@theia/terminal/lib/browser/base/terminal-service'; -import { EnvVariablesServer } from '@theia/core/lib/common/env-variables'; import URI from '@theia/core/lib/common/uri'; import { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/frontend-application-config-provider'; import { environment } from '@theia/core/shared/@theia/application-package/lib/environment'; @@ -66,19 +63,20 @@ import { CustomEditorWidget } from '../../main/browser/custom-editors/custom-edi import { StandaloneServices } from '@theia/monaco-editor-core/esm/vs/editor/standalone/browser/standaloneServices'; import { ILanguageService } from '@theia/monaco-editor-core/esm/vs/editor/common/languages/language'; import { LanguageService } from '@theia/monaco-editor-core/esm/vs/editor/common/services/languageService'; -import { Measurement, Stopwatch } from '@theia/core/lib/common'; import { Uint8ArrayReadBuffer, Uint8ArrayWriteBuffer } from '@theia/core/lib/common/message-rpc/uint8-array-message-buffer'; import { BasicChannel } from '@theia/core/lib/common/message-rpc/channel'; import { NotebookTypeRegistry, NotebookService, NotebookRendererMessagingService } from '@theia/notebook/lib/browser'; +import { + AbstractHostedPluginSupport, PluginContributions, PluginHost, + ALL_ACTIVATION_EVENT, isConnectionScopedBackendPlugin +} from '../common/hosted-plugin'; -export type PluginHost = 'frontend' | string; export type DebugActivationEvent = 'onDebugResolve' | 'onDebugInitialConfigurations' | 'onDebugAdapterProtocolTracker' | 'onDebugDynamicConfigurations'; export const PluginProgressLocation = 'plugin'; -export const ALL_ACTIVATION_EVENT = '*'; @injectable() -export class HostedPluginSupport { +export class HostedPluginSupport extends AbstractHostedPluginSupport> { protected static ADDITIONAL_ACTIVATION_EVENTS_ENV = 'ADDITIONAL_ACTIVATION_EVENTS'; protected static BUILTIN_ACTIVATION_EVENTS = [ @@ -104,38 +102,18 @@ export class HostedPluginSupport { 'onNotebookSerializer' ]; - protected readonly clientId = UUID.uuid4(); - - protected container: interfaces.Container; - - @inject(ILogger) - protected readonly logger: ILogger; - - @inject(HostedPluginServer) - protected readonly server: RpcProxy; - @inject(HostedPluginWatcher) protected readonly watcher: HostedPluginWatcher; @inject(PluginContributionHandler) protected readonly contributionHandler: PluginContributionHandler; - @inject(ContributionProvider) - @named(MainPluginApiProvider) - protected readonly mainPluginApiProviders: ContributionProvider; - - @inject(PluginServer) - protected readonly pluginServer: PluginServer; - @inject(PreferenceProviderProvider) protected readonly preferenceProviderProvider: PreferenceProviderProvider; @inject(PreferenceServiceImpl) protected readonly preferenceServiceImpl: PreferenceServiceImpl; - @inject(PluginPathsService) - protected readonly pluginPathsService: PluginPathsService; - @inject(WorkspaceService) protected readonly workspaceService: WorkspaceService; @@ -190,48 +168,20 @@ export class HostedPluginSupport { @inject(TerminalService) protected readonly terminalService: TerminalService; - @inject(EnvVariablesServer) - protected readonly envServer: EnvVariablesServer; - @inject(JsonSchemaStore) protected readonly jsonSchemaStore: JsonSchemaStore; @inject(PluginCustomEditorRegistry) protected readonly customEditorRegistry: PluginCustomEditorRegistry; - @inject(Stopwatch) - protected readonly stopwatch: Stopwatch; - - protected theiaReadyPromise: Promise; - - protected readonly managers = new Map(); - - protected readonly contributions = new Map(); - - protected readonly activationEvents = new Set(); - - protected readonly onDidChangePluginsEmitter = new Emitter(); - readonly onDidChangePlugins = this.onDidChangePluginsEmitter.event; - - protected readonly deferredWillStart = new Deferred(); - /** - * Resolves when the initial plugins are loaded and about to be started. - */ - get willStart(): Promise { - return this.deferredWillStart.promise; - } - - protected readonly deferredDidStart = new Deferred(); - /** - * Resolves when the initial plugins are started. - */ - get didStart(): Promise { - return this.deferredDidStart.promise; + constructor() { + super(generateUuid()); } @postConstruct() - protected init(): void { - this.theiaReadyPromise = Promise.all([this.preferenceServiceImpl.ready, this.workspaceService.roots]); + protected override init(): void { + super.init(); + this.workspaceService.onWorkspaceChanged(() => this.updateStoragePath()); const languageService = (StandaloneServices.get(ILanguageService) as LanguageService); @@ -277,254 +227,49 @@ export class HostedPluginSupport { }); } - get plugins(): PluginMetadata[] { - const plugins: PluginMetadata[] = []; - this.contributions.forEach(contributions => plugins.push(contributions.plugin.metadata)); - return plugins; + protected createTheiaReadyPromise(): Promise { + return Promise.all([this.preferenceServiceImpl.ready, this.workspaceService.roots]); } - getPlugin(id: PluginIdentifiers.UnversionedId): DeployedPlugin | undefined { - const contributions = this.contributions.get(id); - return contributions && contributions.plugin; + protected override runOperation(operation: () => Promise): Promise { + return this.progressService.withProgress('', PluginProgressLocation, () => this.doLoad()); } - /** do not call it, except from the plugin frontend contribution */ - onStart(container: interfaces.Container): void { - this.container = container; - this.load(); - this.watcher.onDidDeploy(() => this.load()); + protected override afterStart(): void { this.server.onDidOpenConnection(() => this.load()); } - protected loadQueue: Promise = Promise.resolve(undefined); - load = debounce(() => this.loadQueue = this.loadQueue.then(async () => { - try { - await this.progressService.withProgress('', PluginProgressLocation, () => this.doLoad()); - } catch (e) { - console.error('Failed to load plugins:', e); - } - }), 50, { leading: true }); + // Only load connection-scoped plugins + protected acceptPlugin(plugin: DeployedPlugin): boolean { + return isConnectionScopedBackendPlugin(plugin); + } + + protected override async beforeSyncPlugins(toDisconnect: DisposableCollection): Promise { + await super.beforeSyncPlugins(toDisconnect); - protected async doLoad(): Promise { - const toDisconnect = new DisposableCollection(Disposable.create(() => { /* mark as connected */ })); toDisconnect.push(Disposable.create(() => this.preserveWebviews())); this.server.onDidCloseConnection(() => toDisconnect.dispose()); + } - // process empty plugins as well in order to properly remove stale plugin widgets - await this.syncPlugins(); - - // it has to be resolved before awaiting layout is initialized - // otherwise clients can hang forever in the initialization phase - this.deferredWillStart.resolve(); - + protected override async beforeLoadContributions(toDisconnect: DisposableCollection): Promise { // make sure that the previous state, including plugin widgets, is restored // and core layout is initialized, i.e. explorer, scm, debug views are already added to the shell // but shell is not yet revealed await this.appState.reachedState('initialized_layout'); + } - if (toDisconnect.disposed) { - // if disconnected then don't try to load plugin contributions - return; - } - const contributionsByHost = this.loadContributions(toDisconnect); - + protected override async afterLoadContributions(toDisconnect: DisposableCollection): Promise { await this.viewRegistry.initWidgets(); // remove restored plugin widgets which were not registered by contributions this.viewRegistry.removeStaleWidgets(); - await this.theiaReadyPromise; - - if (toDisconnect.disposed) { - // if disconnected then don't try to init plugin code and dynamic contributions - return; - } - await this.startPlugins(contributionsByHost, toDisconnect); - - this.deferredDidStart.resolve(); - } - - /** - * Sync loaded and deployed plugins: - * - undeployed plugins are unloaded - * - newly deployed plugins are initialized - */ - protected async syncPlugins(): Promise { - let initialized = 0; - const waitPluginsMeasurement = this.measure('waitForDeployment'); - let syncPluginsMeasurement: Measurement | undefined; - - const toUnload = new Set(this.contributions.keys()); - let didChangeInstallationStatus = false; - try { - 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 versionedId of deployedPluginIds) { - const unversionedId = PluginIdentifiers.unversionedFromVersioned(versionedId); - toUnload.delete(unversionedId); - if (!this.contributions.has(unversionedId)) { - newPluginIds.push(versionedId); - } - } - for (const pluginId of toUnload) { - 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) { - plugin.metadata.outOfSync = didChangeInstallationStatus = true; - } - } - for (const contribution of this.contributions.values()) { - if (contribution.plugin.metadata.outOfSync && !uninstalledPluginIds.includes(PluginIdentifiers.componentsToVersionedId(contribution.plugin.metadata.model))) { - contribution.plugin.metadata.outOfSync = false; - didChangeInstallationStatus = true; - } - } - if (newPluginIds.length) { - const plugins = await this.server.getDeployedPlugins({ pluginIds: newPluginIds }); - for (const plugin of plugins) { - 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))); - initialized++; - } - } - } finally { - if (initialized || toUnload.size || didChangeInstallationStatus) { - this.onDidChangePluginsEmitter.fire(undefined); - } - - if (!syncPluginsMeasurement) { - // await didn't complete normally - waitPluginsMeasurement.error('Backend deployment failed.'); - } - } - if (initialized > 0) { - // Only log sync measurement if there are were plugins to sync. - syncPluginsMeasurement?.log(`Sync of ${this.getPluginCount(initialized)}`); - } else { - syncPluginsMeasurement.stop(); - } } - /** - * Always synchronous in order to simplify handling disconnections. - * @throws never - */ - protected loadContributions(toDisconnect: DisposableCollection): Map { - let loaded = 0; - const loadPluginsMeasurement = this.measure('loadPlugins'); - - const hostContributions = new Map(); - console.log(`[${this.clientId}] Loading plugin contributions`); - for (const contributions of this.contributions.values()) { - const plugin = contributions.plugin.metadata; - const pluginId = plugin.model.id; - - if (contributions.state === PluginContributions.State.INITIALIZING) { - contributions.state = PluginContributions.State.LOADING; - contributions.push(Disposable.create(() => console.log(`[${pluginId}]: Unloaded plugin.`))); - contributions.push(this.contributionHandler.handleContributions(this.clientId, contributions.plugin)); - contributions.state = PluginContributions.State.LOADED; - console.debug(`[${this.clientId}][${pluginId}]: Loaded contributions.`); - loaded++; - } - - if (contributions.state === PluginContributions.State.LOADED) { - contributions.state = PluginContributions.State.STARTING; - const host = plugin.model.entryPoint.frontend ? 'frontend' : plugin.host; - const dynamicContributions = hostContributions.get(host) || []; - dynamicContributions.push(contributions); - hostContributions.set(host, dynamicContributions); - toDisconnect.push(Disposable.create(() => { - contributions!.state = PluginContributions.State.LOADED; - console.debug(`[${this.clientId}][${pluginId}]: Disconnected.`); - })); - } - } - if (loaded > 0) { - // Only log load measurement if there are were plugins to load. - loadPluginsMeasurement?.log(`Load contributions of ${this.getPluginCount(loaded)}`); - } else { - loadPluginsMeasurement.stop(); - } - - return hostContributions; + protected handleContributions(plugin: DeployedPlugin): Disposable { + return this.contributionHandler.handleContributions(this.clientId, plugin); } - protected async startPlugins(contributionsByHost: Map, toDisconnect: DisposableCollection): Promise { - let started = 0; - const startPluginsMeasurement = this.measure('startPlugins'); - - const [hostLogPath, hostStoragePath, hostGlobalStoragePath] = await Promise.all([ - this.pluginPathsService.getHostLogPath(), - this.getStoragePath(), - this.getHostGlobalStoragePath() - ]); - - if (toDisconnect.disposed) { - return; - } - - const thenable: Promise[] = []; - const configStorage: ConfigStorage = { - hostLogPath, - hostStoragePath, - hostGlobalStoragePath - }; - - for (const [host, hostContributions] of contributionsByHost) { - // do not start plugins for electron browser - if (host === 'frontend' && environment.electron.is()) { - continue; - } - - const manager = await this.obtainManager(host, hostContributions, toDisconnect); - if (!manager) { - continue; - } - - const plugins = hostContributions.map(contributions => contributions.plugin.metadata); - thenable.push((async () => { - try { - const activationEvents = [...this.activationEvents]; - await manager.$start({ plugins, configStorage, activationEvents }); - if (toDisconnect.disposed) { - return; - } - console.log(`[${this.clientId}] Starting plugins.`); - for (const contributions of hostContributions) { - started++; - const plugin = contributions.plugin; - const id = plugin.metadata.model.id; - contributions.state = PluginContributions.State.STARTED; - console.debug(`[${this.clientId}][${id}]: Started plugin.`); - toDisconnect.push(contributions.push(Disposable.create(() => { - console.debug(`[${this.clientId}][${id}]: Stopped plugin.`); - manager.$stop(id); - }))); - - this.activateByWorkspaceContains(manager, plugin); - } - } catch (e) { - console.error(`Failed to start plugins for '${host}' host`, e); - } - })()); - } - - await Promise.all(thenable); - await this.activateByEvent('onStartupFinished'); - if (toDisconnect.disposed) { - return; - } - - if (started > 0) { - startPluginsMeasurement.log(`Start of ${this.getPluginCount(started)}`); - } else { - startPluginsMeasurement.stop(); - } + protected override handlePluginStarted(manager: PluginManagerExt, plugin: DeployedPlugin): void { + this.activateByWorkspaceContains(manager, plugin); } protected async obtainManager(host: string, hostContributions: PluginContributions[], toDisconnect: DisposableCollection): Promise { @@ -646,14 +391,6 @@ export class HostedPluginSupport { return globalStorageFolderFsPath; } - async activateByEvent(activationEvent: string): Promise { - if (this.activationEvents.has(activationEvent)) { - return; - } - this.activationEvents.add(activationEvent); - await Promise.all(Array.from(this.managers.values(), manager => manager.$activateByEvent(activationEvent))); - } - async activateByViewContainer(viewContainerId: string): Promise { await Promise.all(this.viewRegistry.getContainerViews(viewContainerId).map(viewId => this.activateByView(viewId))); } @@ -808,22 +545,6 @@ export class HostedPluginSupport { } } - async activatePlugin(id: string): Promise { - const activation = []; - for (const manager of this.managers.values()) { - activation.push(manager.$activatePlugin(id)); - } - await Promise.all(activation); - } - - protected measure(name: string): Measurement { - return this.stopwatch.start(name, { context: this.clientId }); - } - - protected getPluginCount(plugins: number): string { - return `${plugins} plugin${plugins === 1 ? '' : 's'}`; - } - protected readonly webviewsToRestore = new Map(); protected readonly webviewRevivers = new Map Promise>(); @@ -889,22 +610,3 @@ export class HostedPluginSupport { } } - -export class PluginContributions extends DisposableCollection { - constructor( - readonly plugin: DeployedPlugin - ) { - super(); - } - state: PluginContributions.State = PluginContributions.State.INITIALIZING; -} - -export namespace PluginContributions { - export enum State { - INITIALIZING = 0, - LOADING = 1, - LOADED = 2, - STARTING = 3, - STARTED = 4 - } -} diff --git a/packages/plugin-ext/src/hosted/browser/worker/debug-stub.ts b/packages/plugin-ext/src/hosted/browser/worker/debug-stub.ts index 9dd3e48b64440..f37b9b0a2f431 100644 --- a/packages/plugin-ext/src/hosted/browser/worker/debug-stub.ts +++ b/packages/plugin-ext/src/hosted/browser/worker/debug-stub.ts @@ -15,12 +15,13 @@ // ***************************************************************************** // eslint-disable-next-line @theia/runtime-import-check +import { interfaces } from '@theia/core/shared/inversify'; import { DebugExtImpl } from '../../../plugin/debug/debug-ext'; -import { RPCProtocol } from '../../../common/rpc-protocol'; /* eslint-disable @typescript-eslint/no-explicit-any */ -export function createDebugExtStub(rpc: RPCProtocol): DebugExtImpl { - return new Proxy(new DebugExtImpl(rpc), { +export function createDebugExtStub(container: interfaces.Container): DebugExtImpl { + const delegate = container.get(DebugExtImpl); + return new Proxy(delegate, { apply: function (target, that, args): void { console.error('Debug API works only in plugin container'); } diff --git a/packages/plugin-ext/src/hosted/browser/worker/worker-env-ext.ts b/packages/plugin-ext/src/hosted/browser/worker/worker-env-ext.ts index 993827d6bd50c..bab5d3e39dfa5 100644 --- a/packages/plugin-ext/src/hosted/browser/worker/worker-env-ext.ts +++ b/packages/plugin-ext/src/hosted/browser/worker/worker-env-ext.ts @@ -14,17 +14,18 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** +import { injectable } from '@theia/core/shared/inversify'; import { EnvExtImpl } from '../../../plugin/env'; -import { RPCProtocol } from '../../../common/rpc-protocol'; /** * Worker specific implementation not returning any FileSystem details * Extending the common class */ +@injectable() export class WorkerEnvExtImpl extends EnvExtImpl { - constructor(rpc: RPCProtocol) { - super(rpc); + constructor() { + super(); } /** diff --git a/packages/plugin-ext/src/hosted/browser/worker/worker-main.ts b/packages/plugin-ext/src/hosted/browser/worker/worker-main.ts index caae9776e2f18..f24f83223e6d2 100644 --- a/packages/plugin-ext/src/hosted/browser/worker/worker-main.ts +++ b/packages/plugin-ext/src/hosted/browser/worker/worker-main.ts @@ -15,13 +15,12 @@ // ***************************************************************************** // eslint-disable-next-line import/no-extraneous-dependencies import 'reflect-metadata'; -import { BasicChannel } from '@theia/core/lib/common/message-rpc/channel'; -import { Uint8ArrayReadBuffer, Uint8ArrayWriteBuffer } from '@theia/core/lib/common/message-rpc/uint8-array-message-buffer'; +import { Container } from '@theia/core/shared/inversify'; import * as theia from '@theia/plugin'; -import { emptyPlugin, MAIN_RPC_CONTEXT, Plugin, TerminalServiceExt } from '../../../common/plugin-api-rpc'; +import { emptyPlugin, MAIN_RPC_CONTEXT, Plugin } from '../../../common/plugin-api-rpc'; import { ExtPluginApi } from '../../../common/plugin-ext-api-contribution'; import { getPluginId, PluginMetadata } from '../../../common/plugin-protocol'; -import { RPCProtocolImpl } from '../../../common/rpc-protocol'; +import { RPCProtocol, RPCProtocolImpl } from '../../../common/rpc-protocol'; import { ClipboardExt } from '../../../plugin/clipboard-ext'; import { EditorsAndDocumentsExtImpl } from '../../../plugin/editors-and-documents'; import { MessageRegistryExt } from '../../../plugin/message-registry'; @@ -29,14 +28,13 @@ import { createAPIFactory } from '../../../plugin/plugin-context'; import { PluginManagerExtImpl } from '../../../plugin/plugin-manager'; import { KeyValueStorageProxy } from '../../../plugin/plugin-storage'; import { PreferenceRegistryExtImpl } from '../../../plugin/preference-registry'; -import { SecretsExtImpl } from '../../../plugin/secrets-ext'; -import { TerminalServiceExtImpl } from '../../../plugin/terminal-ext'; import { WebviewsExtImpl } from '../../../plugin/webviews'; import { WorkspaceExtImpl } from '../../../plugin/workspace'; -import { createDebugExtStub } from './debug-stub'; import { loadManifest } from './plugin-manifest-loader'; -import { WorkerEnvExtImpl } from './worker-env-ext'; +import { EnvExtImpl } from '../../../plugin/env'; +import { DebugExtImpl } from '../../../plugin/debug/debug-ext'; import { LocalizationExtImpl } from '../../../plugin/localization-ext'; +import pluginHostModule from './worker-plugin-module'; // eslint-disable-next-line @typescript-eslint/no-explicit-any const ctx = self as any; @@ -44,20 +42,6 @@ const ctx = self as any; const pluginsApiImpl = new Map(); const pluginsModulesNames = new Map(); -const channel = new BasicChannel(() => { - const writeBuffer = new Uint8ArrayWriteBuffer(); - writeBuffer.onCommit(buffer => { - ctx.postMessage(buffer); - }); - return writeBuffer; -}); -// eslint-disable-next-line @typescript-eslint/no-explicit-any -addEventListener('message', (message: any) => { - channel.onMessageEmitter.fire(() => new Uint8ArrayReadBuffer(message.data)); -}); - -const rpc = new RPCProtocolImpl(channel); - const scripts = new Set(); function initialize(contextPath: string, pluginMetadata: PluginMetadata): void { @@ -68,112 +52,116 @@ function initialize(contextPath: string, pluginMetadata: PluginMetadata): void { scripts.add(path); } } -const envExt = new WorkerEnvExtImpl(rpc); -const storageProxy = new KeyValueStorageProxy(rpc); -const editorsAndDocuments = new EditorsAndDocumentsExtImpl(rpc); -const messageRegistryExt = new MessageRegistryExt(rpc); -const workspaceExt = new WorkspaceExtImpl(rpc, editorsAndDocuments, messageRegistryExt); -const preferenceRegistryExt = new PreferenceRegistryExtImpl(rpc, workspaceExt); -const debugExt = createDebugExtStub(rpc); -const clipboardExt = new ClipboardExt(rpc); -const webviewExt = new WebviewsExtImpl(rpc, workspaceExt); -const secretsExt = new SecretsExtImpl(rpc); -const localizationExt = new LocalizationExtImpl(rpc); -const terminalService: TerminalServiceExt = new TerminalServiceExtImpl(rpc); - -const pluginManager = new PluginManagerExtImpl({ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - loadPlugin(plugin: Plugin): any { - if (plugin.pluginPath) { - if (isElectron()) { - ctx.importScripts(plugin.pluginPath); - } else { - if (plugin.lifecycle.frontendModuleName) { - // Set current module name being imported - ctx.frontendModuleName = plugin.lifecycle.frontendModuleName; - } - ctx.importScripts('/hostedPlugin/' + getPluginId(plugin.model) + '/' + plugin.pluginPath); - } - } +const container = new Container(); +container.load(pluginHostModule); + +const rpc: RPCProtocol = container.get(RPCProtocolImpl); +const pluginManager = container.get(PluginManagerExtImpl); +pluginManager.setPluginHost({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + loadPlugin(plugin: Plugin): any { + if (plugin.pluginPath) { + if (isElectron()) { + ctx.importScripts(plugin.pluginPath); + } else { + if (plugin.lifecycle.frontendModuleName) { + // Set current module name being imported + ctx.frontendModuleName = plugin.lifecycle.frontendModuleName; + } - if (plugin.lifecycle.frontendModuleName) { - if (!ctx[plugin.lifecycle.frontendModuleName]) { - console.error(`WebWorker: Cannot start plugin "${plugin.model.name}". Frontend plugin not found: "${plugin.lifecycle.frontendModuleName}"`); - return; + ctx.importScripts('/hostedPlugin/' + getPluginId(plugin.model) + '/' + plugin.pluginPath); + } } - return ctx[plugin.lifecycle.frontendModuleName]; - } - }, - async init(rawPluginData: PluginMetadata[]): Promise<[Plugin[], Plugin[]]> { - const result: Plugin[] = []; - const foreign: Plugin[] = []; - // Process the plugins concurrently, making sure to keep the order. - const plugins = await Promise.all<{ - /** Where to push the plugin: `result` or `foreign` */ - target: Plugin[], - plugin: Plugin - }>(rawPluginData.map(async plg => { - const pluginModel = plg.model; - const pluginLifecycle = plg.lifecycle; - if (pluginModel.entryPoint!.frontend) { - let frontendInitPath = pluginLifecycle.frontendInitPath; - if (frontendInitPath) { - initialize(frontendInitPath, plg); - } else { - frontendInitPath = ''; + + if (plugin.lifecycle.frontendModuleName) { + if (!ctx[plugin.lifecycle.frontendModuleName]) { + console.error(`WebWorker: Cannot start plugin "${plugin.model.name}". Frontend plugin not found: "${plugin.lifecycle.frontendModuleName}"`); + return; } - const rawModel = await loadManifest(pluginModel); - const plugin: Plugin = { - pluginPath: pluginModel.entryPoint.frontend!, - pluginFolder: pluginModel.packagePath, - pluginUri: pluginModel.packageUri, - model: pluginModel, - lifecycle: pluginLifecycle, - rawModel, - isUnderDevelopment: !!plg.isUnderDevelopment - }; - const apiImpl = apiFactory(plugin); - pluginsApiImpl.set(plugin.model.id, apiImpl); - pluginsModulesNames.set(plugin.lifecycle.frontendModuleName!, plugin); - return { target: result, plugin }; - } else { - return { - target: foreign, - plugin: { - pluginPath: pluginModel.entryPoint.backend, + return ctx[plugin.lifecycle.frontendModuleName]; + } + }, + async init(rawPluginData: PluginMetadata[]): Promise<[Plugin[], Plugin[]]> { + const result: Plugin[] = []; + const foreign: Plugin[] = []; + // Process the plugins concurrently, making sure to keep the order. + const plugins = await Promise.all<{ + /** Where to push the plugin: `result` or `foreign` */ + target: Plugin[], + plugin: Plugin + }>(rawPluginData.map(async plg => { + const pluginModel = plg.model; + const pluginLifecycle = plg.lifecycle; + if (pluginModel.entryPoint!.frontend) { + let frontendInitPath = pluginLifecycle.frontendInitPath; + if (frontendInitPath) { + initialize(frontendInitPath, plg); + } else { + frontendInitPath = ''; + } + const rawModel = await loadManifest(pluginModel); + const plugin: Plugin = { + pluginPath: pluginModel.entryPoint.frontend!, pluginFolder: pluginModel.packagePath, pluginUri: pluginModel.packageUri, model: pluginModel, lifecycle: pluginLifecycle, - get rawModel(): never { - throw new Error('not supported'); - }, + rawModel, isUnderDevelopment: !!plg.isUnderDevelopment - } - }; - } - })); - // Collect the ordered plugins and insert them in the target array: - for (const { target, plugin } of plugins) { - target.push(plugin); - } - return [result, foreign]; - }, - initExtApi(extApi: ExtPluginApi[]): void { - for (const api of extApi) { - try { - if (api.frontendExtApi) { - ctx.importScripts(api.frontendExtApi.initPath); - ctx[api.frontendExtApi.initVariable][api.frontendExtApi.initFunction](rpc, pluginsModulesNames); + }; + const apiImpl = apiFactory(plugin); + pluginsApiImpl.set(plugin.model.id, apiImpl); + pluginsModulesNames.set(plugin.lifecycle.frontendModuleName!, plugin); + return { target: result, plugin }; + } else { + return { + target: foreign, + plugin: { + pluginPath: pluginModel.entryPoint.backend, + pluginFolder: pluginModel.packagePath, + pluginUri: pluginModel.packageUri, + model: pluginModel, + lifecycle: pluginLifecycle, + get rawModel(): never { + throw new Error('not supported'); + }, + isUnderDevelopment: !!plg.isUnderDevelopment + } + }; } + })); + // Collect the ordered plugins and insert them in the target array: + for (const { target, plugin } of plugins) { + target.push(plugin); + } + return [result, foreign]; + }, + initExtApi(extApi: ExtPluginApi[]): void { + for (const api of extApi) { + try { + if (api.frontendExtApi) { + ctx.importScripts(api.frontendExtApi.initPath); + ctx[api.frontendExtApi.initVariable][api.frontendExtApi.initFunction](rpc, pluginsModulesNames); + } - } catch (e) { - console.error(e); + } catch (e) { + console.error(e); + } } } - } -}, envExt, terminalService, storageProxy, secretsExt, preferenceRegistryExt, webviewExt, localizationExt, rpc); + }); + +const envExt = container.get(EnvExtImpl); +const debugExt = container.get(DebugExtImpl); +const preferenceRegistryExt = container.get(PreferenceRegistryExtImpl); +const editorsAndDocuments = container.get(EditorsAndDocumentsExtImpl); +const workspaceExt = container.get(WorkspaceExtImpl); +const messageRegistryExt = container.get(MessageRegistryExt); +const clipboardExt = container.get(ClipboardExt); +const webviewExt = container.get(WebviewsExtImpl); +const localizationExt = container.get(LocalizationExtImpl); +const storageProxy = container.get(KeyValueStorageProxy); const apiFactory = createAPIFactory( rpc, diff --git a/packages/plugin-ext/src/hosted/browser/worker/worker-plugin-module.ts b/packages/plugin-ext/src/hosted/browser/worker/worker-plugin-module.ts new file mode 100644 index 0000000000000..ebace3685d069 --- /dev/null +++ b/packages/plugin-ext/src/hosted/browser/worker/worker-plugin-module.ts @@ -0,0 +1,73 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource 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-only WITH Classpath-exception-2.0 +// ***************************************************************************** +// eslint-disable-next-line import/no-extraneous-dependencies +import 'reflect-metadata'; +import { ContainerModule } from '@theia/core/shared/inversify'; +import { BasicChannel } from '@theia/core/lib/common/message-rpc/channel'; +import { Uint8ArrayReadBuffer, Uint8ArrayWriteBuffer } from '@theia/core/lib/common/message-rpc/uint8-array-message-buffer'; +import { LocalizationExt } from '../../../common/plugin-api-rpc'; +import { RPCProtocol, RPCProtocolImpl } from '../../../common/rpc-protocol'; +import { ClipboardExt } from '../../../plugin/clipboard-ext'; +import { EditorsAndDocumentsExtImpl } from '../../../plugin/editors-and-documents'; +import { MessageRegistryExt } from '../../../plugin/message-registry'; +import { PluginManagerExtImpl } from '../../../plugin/plugin-manager'; +import { KeyValueStorageProxy } from '../../../plugin/plugin-storage'; +import { PreferenceRegistryExtImpl } from '../../../plugin/preference-registry'; +import { SecretsExtImpl } from '../../../plugin/secrets-ext'; +import { TerminalServiceExtImpl } from '../../../plugin/terminal-ext'; +import { WebviewsExtImpl } from '../../../plugin/webviews'; +import { WorkspaceExtImpl } from '../../../plugin/workspace'; +import { createDebugExtStub } from './debug-stub'; +import { EnvExtImpl } from '../../../plugin/env'; +import { WorkerEnvExtImpl } from './worker-env-ext'; +import { DebugExtImpl } from '../../../plugin/debug/debug-ext'; +import { LocalizationExtImpl } from '../../../plugin/localization-ext'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const ctx = self as any; + +export default new ContainerModule(bind => { + const channel = new BasicChannel(() => { + const writeBuffer = new Uint8ArrayWriteBuffer(); + writeBuffer.onCommit(buffer => { + ctx.postMessage(buffer); + }); + return writeBuffer; + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + addEventListener('message', (message: any) => { + channel.onMessageEmitter.fire(() => new Uint8ArrayReadBuffer(message.data)); + }); + + const rpc = new RPCProtocolImpl(channel); + + bind(RPCProtocol).toConstantValue(rpc); + + bind(PluginManagerExtImpl).toSelf().inSingletonScope(); + bind(EnvExtImpl).to(WorkerEnvExtImpl).inSingletonScope(); + bind(LocalizationExt).to(LocalizationExtImpl).inSingletonScope(); + bind(KeyValueStorageProxy).toSelf().inSingletonScope(); + bind(SecretsExtImpl).toSelf().inSingletonScope(); + bind(PreferenceRegistryExtImpl).toSelf().inSingletonScope(); + bind(DebugExtImpl).toDynamicValue(({ container }) => createDebugExtStub(container)) + .inSingletonScope(); + bind(EditorsAndDocumentsExtImpl).toSelf().inSingletonScope(); + bind(WorkspaceExtImpl).toSelf().inSingletonScope(); + bind(MessageRegistryExt).toSelf().inSingletonScope(); + bind(ClipboardExt).toSelf().inSingletonScope(); + bind(WebviewsExtImpl).toSelf().inSingletonScope(); + bind(TerminalServiceExtImpl).toSelf().inSingletonScope(); +}); diff --git a/packages/plugin-ext/src/hosted/common/hosted-plugin.ts b/packages/plugin-ext/src/hosted/common/hosted-plugin.ts new file mode 100644 index 0000000000000..422bd76a4fe1a --- /dev/null +++ b/packages/plugin-ext/src/hosted/common/hosted-plugin.ts @@ -0,0 +1,456 @@ +// ***************************************************************************** +// Copyright (C) 2018 Red Hat, Inc. 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-only WITH Classpath-exception-2.0 +// ***************************************************************************** +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +// some code copied and modified from https://github.com/microsoft/vscode/blob/da5fb7d5b865aa522abc7e82c10b746834b98639/src/vs/workbench/api/node/extHostExtensionService.ts + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import debounce = require('@theia/core/shared/lodash.debounce'); +import { injectable, inject, interfaces, named, postConstruct } from '@theia/core/shared/inversify'; +import { PluginMetadata, HostedPluginServer, DeployedPlugin, PluginServer, PluginIdentifiers } from '../../common/plugin-protocol'; +import { AbstractPluginManagerExt, ConfigStorage } from '../../common/plugin-api-rpc'; +import { + Disposable, DisposableCollection, Emitter, + ILogger, ContributionProvider, + RpcProxy +} from '@theia/core'; +import { MainPluginApiProvider } from '../../common/plugin-ext-api-contribution'; +import { PluginPathsService } from '../../main/common/plugin-paths-protocol'; +import { Deferred } from '@theia/core/lib/common/promise-util'; +import { EnvVariablesServer } from '@theia/core/lib/common/env-variables'; +import { environment } from '@theia/core/shared/@theia/application-package/lib/environment'; +import { Measurement, Stopwatch } from '@theia/core/lib/common'; + +export type PluginHost = 'frontend' | string; + +export const ALL_ACTIVATION_EVENT = '*'; + +export function isConnectionScopedBackendPlugin(plugin: DeployedPlugin): boolean { + const entryPoint = plugin.metadata.model.entryPoint; + + // A plugin doesn't have to have any entry-point if it doesn't need the activation handler, + // in which case it's assumed to be a backend plugin. + return !entryPoint.headless || !!entryPoint.backend; +} + +@injectable() +export abstract class AbstractHostedPluginSupport, HPS extends HostedPluginServer | RpcProxy> { + + protected container: interfaces.Container; + + @inject(ILogger) + protected readonly logger: ILogger; + + @inject(HostedPluginServer) + protected readonly server: HPS; + + @inject(ContributionProvider) + @named(MainPluginApiProvider) + protected readonly mainPluginApiProviders: ContributionProvider; + + @inject(PluginServer) + protected readonly pluginServer: PluginServer; + + @inject(PluginPathsService) + protected readonly pluginPathsService: PluginPathsService; + + @inject(EnvVariablesServer) + protected readonly envServer: EnvVariablesServer; + + @inject(Stopwatch) + protected readonly stopwatch: Stopwatch; + + protected theiaReadyPromise: Promise; + + protected readonly managers = new Map(); + + protected readonly contributions = new Map(); + + protected readonly activationEvents = new Set(); + + protected readonly onDidChangePluginsEmitter = new Emitter(); + readonly onDidChangePlugins = this.onDidChangePluginsEmitter.event; + + protected readonly deferredWillStart = new Deferred(); + /** + * Resolves when the initial plugins are loaded and about to be started. + */ + get willStart(): Promise { + return this.deferredWillStart.promise; + } + + protected readonly deferredDidStart = new Deferred(); + /** + * Resolves when the initial plugins are started. + */ + get didStart(): Promise { + return this.deferredDidStart.promise; + } + + constructor(protected readonly clientId: string) { } + + @postConstruct() + protected init(): void { + this.theiaReadyPromise = this.createTheiaReadyPromise(); + } + + protected abstract createTheiaReadyPromise(): Promise; + + get plugins(): PluginMetadata[] { + const plugins: PluginMetadata[] = []; + this.contributions.forEach(contributions => plugins.push(contributions.plugin.metadata)); + return plugins; + } + + getPlugin(id: PluginIdentifiers.UnversionedId): DeployedPlugin | undefined { + const contributions = this.contributions.get(id); + return contributions && contributions.plugin; + } + + /** do not call it, except from the plugin frontend contribution */ + onStart(container: interfaces.Container): void { + this.container = container; + this.load(); + this.afterStart(); + } + + protected afterStart(): void { + // Nothing to do in the abstract + } + + protected loadQueue: Promise = Promise.resolve(undefined); + load = debounce(() => this.loadQueue = this.loadQueue.then(async () => { + try { + await this.runOperation(() => this.doLoad()); + } catch (e) { + console.error('Failed to load plugins:', e); + } + }), 50, { leading: true }); + + protected runOperation(operation: () => Promise): Promise { + return operation(); + } + + protected async doLoad(): Promise { + const toDisconnect = new DisposableCollection(Disposable.create(() => { /* mark as connected */ })); + + await this.beforeSyncPlugins(toDisconnect); + + // process empty plugins as well in order to properly remove stale plugin widgets + await this.syncPlugins(); + + // it has to be resolved before awaiting layout is initialized + // otherwise clients can hang forever in the initialization phase + this.deferredWillStart.resolve(); + + await this.beforeLoadContributions(toDisconnect); + + if (toDisconnect.disposed) { + // if disconnected then don't try to load plugin contributions + return; + } + const contributionsByHost = this.loadContributions(toDisconnect); + + await this.afterLoadContributions(toDisconnect); + + await this.theiaReadyPromise; + if (toDisconnect.disposed) { + // if disconnected then don't try to init plugin code and dynamic contributions + return; + } + await this.startPlugins(contributionsByHost, toDisconnect); + + this.deferredDidStart.resolve(); + } + + protected beforeSyncPlugins(toDisconnect: DisposableCollection): Promise { + // Nothing to do in the abstract + return Promise.resolve(); + } + + protected beforeLoadContributions(toDisconnect: DisposableCollection): Promise { + // Nothing to do in the abstract + return Promise.resolve(); + } + + protected afterLoadContributions(toDisconnect: DisposableCollection): Promise { + // Nothing to do in the abstract + return Promise.resolve(); + } + + /** + * Sync loaded and deployed plugins: + * - undeployed plugins are unloaded + * - newly deployed plugins are initialized + */ + protected async syncPlugins(): Promise { + let initialized = 0; + const waitPluginsMeasurement = this.measure('waitForDeployment'); + let syncPluginsMeasurement: Measurement | undefined; + + const toUnload = new Set(this.contributions.keys()); + let didChangeInstallationStatus = false; + try { + 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 versionedId of deployedPluginIds) { + const unversionedId = PluginIdentifiers.unversionedFromVersioned(versionedId); + toUnload.delete(unversionedId); + if (!this.contributions.has(unversionedId)) { + newPluginIds.push(versionedId); + } + } + for (const pluginId of toUnload) { + 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) { + plugin.metadata.outOfSync = didChangeInstallationStatus = true; + } + } + for (const contribution of this.contributions.values()) { + if (contribution.plugin.metadata.outOfSync && !uninstalledPluginIds.includes(PluginIdentifiers.componentsToVersionedId(contribution.plugin.metadata.model))) { + contribution.plugin.metadata.outOfSync = false; + didChangeInstallationStatus = true; + } + } + if (newPluginIds.length) { + const deployedPlugins = await this.server.getDeployedPlugins({ pluginIds: newPluginIds }); + + const plugins: DeployedPlugin[] = []; + for (const plugin of deployedPlugins) { + const accepted = this.acceptPlugin(plugin); + if (typeof accepted === 'object') { + plugins.push(accepted); + } else if (accepted) { + plugins.push(plugin); + } + } + + for (const plugin of plugins) { + 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))); + initialized++; + } + } + } finally { + if (initialized || toUnload.size || didChangeInstallationStatus) { + this.onDidChangePluginsEmitter.fire(undefined); + } + + if (!syncPluginsMeasurement) { + // await didn't complete normally + waitPluginsMeasurement.error('Backend deployment failed.'); + } + } + if (initialized > 0) { + // Only log sync measurement if there are were plugins to sync. + syncPluginsMeasurement?.log(`Sync of ${this.getPluginCount(initialized)}`); + } else { + syncPluginsMeasurement?.stop(); + } + } + + /** + * Accept a deployed plugin to load in this host, or reject it, or adapt it for loading. + * The result may be a boolean to accept (`true`) or reject (`false`) the plugin as is, + * or else an adaptation of the original `plugin` to load in its stead. + */ + protected abstract acceptPlugin(plugin: DeployedPlugin): boolean | DeployedPlugin; + + /** + * Always synchronous in order to simplify handling disconnections. + * @throws never + */ + protected loadContributions(toDisconnect: DisposableCollection): Map { + let loaded = 0; + const loadPluginsMeasurement = this.measure('loadPlugins'); + + const hostContributions = new Map(); + console.log(`[${this.clientId}] Loading plugin contributions`); + for (const contributions of this.contributions.values()) { + const plugin = contributions.plugin.metadata; + const pluginId = plugin.model.id; + + if (contributions.state === PluginContributions.State.INITIALIZING) { + contributions.state = PluginContributions.State.LOADING; + contributions.push(Disposable.create(() => console.log(`[${pluginId}]: Unloaded plugin.`))); + contributions.push(this.handleContributions(contributions.plugin)); + contributions.state = PluginContributions.State.LOADED; + console.debug(`[${this.clientId}][${pluginId}]: Loaded contributions.`); + loaded++; + } + + if (contributions.state === PluginContributions.State.LOADED) { + contributions.state = PluginContributions.State.STARTING; + const host = plugin.model.entryPoint.frontend ? 'frontend' : plugin.host; + const dynamicContributions = hostContributions.get(host) || []; + dynamicContributions.push(contributions); + hostContributions.set(host, dynamicContributions); + toDisconnect.push(Disposable.create(() => { + contributions!.state = PluginContributions.State.LOADED; + console.debug(`[${this.clientId}][${pluginId}]: Disconnected.`); + })); + } + } + if (loaded > 0) { + // Only log load measurement if there are were plugins to load. + loadPluginsMeasurement?.log(`Load contributions of ${this.getPluginCount(loaded)}`); + } else { + loadPluginsMeasurement.stop(); + } + + return hostContributions; + } + + protected abstract handleContributions(plugin: DeployedPlugin): Disposable; + + protected async startPlugins(contributionsByHost: Map, toDisconnect: DisposableCollection): Promise { + let started = 0; + const startPluginsMeasurement = this.measure('startPlugins'); + + const [hostLogPath, hostStoragePath, hostGlobalStoragePath] = await Promise.all([ + this.pluginPathsService.getHostLogPath(), + this.getStoragePath(), + this.getHostGlobalStoragePath() + ]); + + if (toDisconnect.disposed) { + return; + } + + const thenable: Promise[] = []; + const configStorage: ConfigStorage = { + hostLogPath, + hostStoragePath, + hostGlobalStoragePath + }; + + for (const [host, hostContributions] of contributionsByHost) { + // do not start plugins for electron browser + if (host === 'frontend' && environment.electron.is()) { + continue; + } + + const manager = await this.obtainManager(host, hostContributions, toDisconnect); + if (!manager) { + continue; + } + + const plugins = hostContributions.map(contributions => contributions.plugin.metadata); + thenable.push((async () => { + try { + const activationEvents = [...this.activationEvents]; + await manager.$start({ plugins, configStorage, activationEvents }); + if (toDisconnect.disposed) { + return; + } + console.log(`[${this.clientId}] Starting plugins.`); + for (const contributions of hostContributions) { + started++; + const plugin = contributions.plugin; + const id = plugin.metadata.model.id; + contributions.state = PluginContributions.State.STARTED; + console.debug(`[${this.clientId}][${id}]: Started plugin.`); + toDisconnect.push(contributions.push(Disposable.create(() => { + console.debug(`[${this.clientId}][${id}]: Stopped plugin.`); + manager.$stop(id); + }))); + + this.handlePluginStarted(manager, plugin); + } + } catch (e) { + console.error(`Failed to start plugins for '${host}' host`, e); + } + })()); + } + + await Promise.all(thenable); + await this.activateByEvent('onStartupFinished'); + if (toDisconnect.disposed) { + return; + } + + if (started > 0) { + startPluginsMeasurement.log(`Start of ${this.getPluginCount(started)}`); + } else { + startPluginsMeasurement.stop(); + } + } + + protected abstract obtainManager(host: string, hostContributions: PluginContributions[], + toDisconnect: DisposableCollection): Promise; + + protected abstract getStoragePath(): Promise; + + protected abstract getHostGlobalStoragePath(): Promise; + + async activateByEvent(activationEvent: string): Promise { + if (this.activationEvents.has(activationEvent)) { + return; + } + this.activationEvents.add(activationEvent); + await Promise.all(Array.from(this.managers.values(), manager => manager.$activateByEvent(activationEvent))); + } + + async activatePlugin(id: string): Promise { + const activation = []; + for (const manager of this.managers.values()) { + activation.push(manager.$activatePlugin(id)); + } + await Promise.all(activation); + } + + protected handlePluginStarted(manager: PM, plugin: DeployedPlugin): void { + // Nothing to do in the abstract + } + + protected measure(name: string): Measurement { + return this.stopwatch.start(name, { context: this.clientId }); + } + + protected getPluginCount(plugins: number): string { + return `${plugins} plugin${plugins === 1 ? '' : 's'}`; + } + +} + +export class PluginContributions extends DisposableCollection { + constructor( + readonly plugin: DeployedPlugin + ) { + super(); + } + state: PluginContributions.State = PluginContributions.State.INITIALIZING; +} + +export namespace PluginContributions { + export enum State { + INITIALIZING = 0, + LOADING = 1, + LOADED = 2, + STARTING = 3, + STARTED = 4 + } +} 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 b3a3f660fbfcc..aaf11b2dd7315 100644 --- a/packages/plugin-ext/src/hosted/node/hosted-plugin-process.ts +++ b/packages/plugin-ext/src/hosted/node/hosted-plugin-process.ts @@ -16,16 +16,16 @@ import { ConnectionErrorHandler, ContributionProvider, ILogger, MessageService } from '@theia/core/lib/common'; import { Deferred } from '@theia/core/lib/common/promise-util'; +import { BinaryMessagePipe } from '@theia/core/lib/node/messaging/binary-message-pipe'; import { createIpcEnv } from '@theia/core/lib/node/messaging/ipc-protocol'; import { inject, injectable, named } from '@theia/core/shared/inversify'; import * as cp from 'child_process'; +import { Duplex } from 'stream'; +import { DeployedPlugin, HostedPluginClient, PLUGIN_HOST_BACKEND, PluginHostEnvironmentVariable, PluginIdentifiers, ServerPluginRunner } from '../../common/plugin-protocol'; import { HostedPluginCliContribution } from './hosted-plugin-cli-contribution'; import { HostedPluginLocalizationService } from './hosted-plugin-localization-service'; -import { ProcessTerminatedMessage, ProcessTerminateMessage } from './hosted-plugin-protocol'; -import { BinaryMessagePipe } from '@theia/core/lib/node/messaging/binary-message-pipe'; -import { DeployedPlugin, HostedPluginClient, PluginHostEnvironmentVariable, PluginIdentifiers, PLUGIN_HOST_BACKEND, ServerPluginRunner } from '../../common/plugin-protocol'; +import { ProcessTerminateMessage, ProcessTerminatedMessage } from './hosted-plugin-protocol'; import psTree = require('ps-tree'); -import { Duplex } from 'stream'; export interface IPCConnectionOptions { readonly serverName: string; diff --git a/packages/plugin-ext/src/hosted/node/plugin-ext-hosted-backend-module.ts b/packages/plugin-ext/src/hosted/node/plugin-ext-hosted-backend-module.ts index 78ce8fe2175e5..ae10070f58ea1 100644 --- a/packages/plugin-ext/src/hosted/node/plugin-ext-hosted-backend-module.ts +++ b/packages/plugin-ext/src/hosted/node/plugin-ext-hosted-backend-module.ts @@ -21,7 +21,7 @@ import { CliContribution } from '@theia/core/lib/node/cli'; import { ConnectionContainerModule } from '@theia/core/lib/node/messaging/connection-container-module'; import { BackendApplicationContribution } from '@theia/core/lib/node/backend-application'; import { MetadataScanner } from './metadata-scanner'; -import { HostedPluginServerImpl } from './plugin-service'; +import { BackendPluginHostableFilter, HostedPluginServerImpl } from './plugin-service'; import { HostedPluginReader } from './plugin-reader'; import { HostedPluginSupport } from './hosted-plugin'; import { TheiaPluginScanner } from './scanners/scanner-theia'; @@ -38,6 +38,7 @@ import { LanguagePackService, languagePackServicePath } from '../../common/langu import { PluginLanguagePackService } from './plugin-language-pack-service'; import { RpcConnectionHandler } from '@theia/core/lib/common/messaging/proxy-factory'; import { ConnectionHandler } from '@theia/core/lib/common/messaging/handler'; +import { isConnectionScopedBackendPlugin } from '../common/hosted-plugin'; const commonHostedConnectionModule = ConnectionContainerModule.create(({ bind, bindBackendService }) => { bind(HostedPluginProcess).toSelf().inSingletonScope(); @@ -48,6 +49,7 @@ const commonHostedConnectionModule = ConnectionContainerModule.create(({ bind, b bind(HostedPluginServerImpl).toSelf().inSingletonScope(); bind(HostedPluginServer).toService(HostedPluginServerImpl); + bind(BackendPluginHostableFilter).toConstantValue(isConnectionScopedBackendPlugin); bindBackendService(hostedServicePath, HostedPluginServer, (server, client) => { server.setClient(client); client.onDidCloseConnection(() => server.dispose()); diff --git a/packages/plugin-ext/src/hosted/node/plugin-host-module.ts b/packages/plugin-ext/src/hosted/node/plugin-host-module.ts new file mode 100644 index 0000000000000..94d22e0ddc474 --- /dev/null +++ b/packages/plugin-ext/src/hosted/node/plugin-host-module.ts @@ -0,0 +1,69 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource 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-only WITH Classpath-exception-2.0 +// ***************************************************************************** +import '@theia/core/shared/reflect-metadata'; +import { ContainerModule } from '@theia/core/shared/inversify'; +import { RPCProtocol, RPCProtocolImpl } from '../../common/rpc-protocol'; +import { AbstractPluginHostRPC, PluginHostRPC, PluginContainerModuleLoader } from './plugin-host-rpc'; +import { AbstractPluginManagerExtImpl, MinimalTerminalServiceExt, PluginManagerExtImpl } from '../../plugin/plugin-manager'; +import { IPCChannel } from '@theia/core/lib/node'; +import { InternalPluginContainerModule } from '../../plugin/node/plugin-container-module'; +import { LocalizationExt } from '../../common/plugin-api-rpc'; +import { EnvExtImpl } from '../../plugin/env'; +import { EnvNodeExtImpl } from '../../plugin/node/env-node-ext'; +import { LocalizationExtImpl } from '../../plugin/localization-ext'; +import { PreferenceRegistryExtImpl } from '../../plugin/preference-registry'; +import { DebugExtImpl } from '../../plugin/debug/debug-ext'; +import { EditorsAndDocumentsExtImpl } from '../../plugin/editors-and-documents'; +import { WorkspaceExtImpl } from '../../plugin/workspace'; +import { MessageRegistryExt } from '../../plugin/message-registry'; +import { ClipboardExt } from '../../plugin/clipboard-ext'; +import { KeyValueStorageProxy, InternalStorageExt } from '../../plugin/plugin-storage'; +import { WebviewsExtImpl } from '../../plugin/webviews'; +import { TerminalServiceExtImpl } from '../../plugin/terminal-ext'; +import { InternalSecretsExt, SecretsExtImpl } from '../../plugin/secrets-ext'; + +export default new ContainerModule(bind => { + const channel = new IPCChannel(); + bind(RPCProtocol).toConstantValue(new RPCProtocolImpl(channel)); + + bind(PluginContainerModuleLoader).toDynamicValue(({ container }) => + (module: ContainerModule) => { + container.load(module); + const internalModule = module as InternalPluginContainerModule; + const pluginApiCache = internalModule.initializeApi?.(container); + return pluginApiCache; + }).inSingletonScope(); + + bind(AbstractPluginHostRPC).toService(PluginHostRPC); + bind(AbstractPluginManagerExtImpl).toService(PluginManagerExtImpl); + bind(PluginManagerExtImpl).toSelf().inSingletonScope(); + bind(PluginHostRPC).toSelf().inSingletonScope(); + bind(EnvExtImpl).to(EnvNodeExtImpl).inSingletonScope(); + bind(LocalizationExt).to(LocalizationExtImpl).inSingletonScope(); + bind(InternalStorageExt).toService(KeyValueStorageProxy); + bind(KeyValueStorageProxy).toSelf().inSingletonScope(); + bind(InternalSecretsExt).toService(SecretsExtImpl); + bind(SecretsExtImpl).toSelf().inSingletonScope(); + bind(PreferenceRegistryExtImpl).toSelf().inSingletonScope(); + bind(DebugExtImpl).toSelf().inSingletonScope(); + bind(EditorsAndDocumentsExtImpl).toSelf().inSingletonScope(); + bind(WorkspaceExtImpl).toSelf().inSingletonScope(); + bind(MessageRegistryExt).toSelf().inSingletonScope(); + bind(ClipboardExt).toSelf().inSingletonScope(); + bind(WebviewsExtImpl).toSelf().inSingletonScope(); + bind(MinimalTerminalServiceExt).toService(TerminalServiceExtImpl); + bind(TerminalServiceExtImpl).toSelf().inSingletonScope(); +}); diff --git a/packages/plugin-ext/src/hosted/node/plugin-host-rpc.ts b/packages/plugin-ext/src/hosted/node/plugin-host-rpc.ts index eefeddaf5e7d0..05c6764a5c3bb 100644 --- a/packages/plugin-ext/src/hosted/node/plugin-host-rpc.ts +++ b/packages/plugin-ext/src/hosted/node/plugin-host-rpc.ts @@ -14,10 +14,15 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** +/* eslint-disable @typescript-eslint/no-explicit-any */ + import { dynamicRequire, removeFromCache } from '@theia/core/lib/node/dynamic-require'; -import { PluginManagerExtImpl } from '../../plugin/plugin-manager'; -import { LocalizationExt, MAIN_RPC_CONTEXT, Plugin, PluginAPIFactory } from '../../common/plugin-api-rpc'; -import { PluginMetadata } from '../../common/plugin-protocol'; +import { ContainerModule, inject, injectable, postConstruct, unmanaged } from '@theia/core/shared/inversify'; +import { AbstractPluginManagerExtImpl, PluginHost, PluginManagerExtImpl } from '../../plugin/plugin-manager'; +import { MAIN_RPC_CONTEXT, Plugin, PluginAPIFactory, PluginManager, + LocalizationExt +} from '../../common/plugin-api-rpc'; +import { PluginMetadata, PluginModel } from '../../common/plugin-protocol'; import { createAPIFactory } from '../../plugin/plugin-context'; import { EnvExtImpl } from '../../plugin/env'; import { PreferenceRegistryExtImpl } from '../../plugin/preference-registry'; @@ -26,95 +31,139 @@ import { DebugExtImpl } from '../../plugin/debug/debug-ext'; import { EditorsAndDocumentsExtImpl } from '../../plugin/editors-and-documents'; import { WorkspaceExtImpl } from '../../plugin/workspace'; import { MessageRegistryExt } from '../../plugin/message-registry'; -import { EnvNodeExtImpl } from '../../plugin/node/env-node-ext'; import { ClipboardExt } from '../../plugin/clipboard-ext'; import { loadManifest } from './plugin-manifest-loader'; import { KeyValueStorageProxy } from '../../plugin/plugin-storage'; import { WebviewsExtImpl } from '../../plugin/webviews'; import { TerminalServiceExtImpl } from '../../plugin/terminal-ext'; import { SecretsExtImpl } from '../../plugin/secrets-ext'; -import { BackendInitializationFn } from '../../common'; import { connectProxyResolver } from './plugin-host-proxy'; import { LocalizationExtImpl } from '../../plugin/localization-ext'; +import { RPCProtocol, ProxyIdentifier } from '../../common/rpc-protocol'; +import { PluginApiCache } from '../../plugin/node/plugin-container-module'; + +/** + * The full set of all possible `Ext` interfaces that a plugin manager can support. + */ +export interface ExtInterfaces { + envExt: EnvExtImpl, + storageExt: KeyValueStorageProxy, + debugExt: DebugExtImpl, + editorsAndDocumentsExt: EditorsAndDocumentsExtImpl, + messageRegistryExt: MessageRegistryExt, + workspaceExt: WorkspaceExtImpl, + preferenceRegistryExt: PreferenceRegistryExtImpl, + clipboardExt: ClipboardExt, + webviewExt: WebviewsExtImpl, + terminalServiceExt: TerminalServiceExtImpl, + secretsExt: SecretsExtImpl, + localizationExt: LocalizationExtImpl +} + +/** + * The RPC proxy identifier keys to set in the RPC object to register our `Ext` interface implementations. + */ +export type RpcKeys> = Partial>> & { + $pluginManager: ProxyIdentifier; +}; + +export const PluginContainerModuleLoader = Symbol('PluginContainerModuleLoader'); +/** + * A function that loads a `PluginContainerModule` exported by a plugin's entry-point + * script, returning the per-`Container` cache of its exported API instances if the + * module has an API factory registered. + */ +export type PluginContainerModuleLoader = (module: ContainerModule) => PluginApiCache | undefined; /** * Handle the RPC calls. + * + * @template PM is the plugin manager (ext) type + * @template PAF is the plugin API factory type + * @template EXT is the type identifying the `Ext` interfaces supported by the plugin manager */ -export class PluginHostRPC { +@injectable() +export abstract class AbstractPluginHostRPC, PAF, EXT extends Partial> { + + @inject(RPCProtocol) + protected readonly rpc: any; - private apiFactory: PluginAPIFactory; + @inject(PluginContainerModuleLoader) + protected readonly loadContainerModule: PluginContainerModuleLoader; - private pluginManager: PluginManagerExtImpl; + @inject(AbstractPluginManagerExtImpl) + protected readonly pluginManager: PM; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - constructor(protected readonly rpc: any) { + protected readonly banner: string; + + protected apiFactory: PAF; + + constructor( + @unmanaged() name: string, + @unmanaged() private readonly backendInitPath: string | undefined, + @unmanaged() private readonly extRpc: RpcKeys) { + this.banner = `${name}(${process.pid}):`; } + @postConstruct() initialize(): void { - const envExt = new EnvNodeExtImpl(this.rpc); - const storageProxy = new KeyValueStorageProxy(this.rpc); - const debugExt = new DebugExtImpl(this.rpc); - const editorsAndDocumentsExt = new EditorsAndDocumentsExtImpl(this.rpc); - const messageRegistryExt = new MessageRegistryExt(this.rpc); - const workspaceExt = new WorkspaceExtImpl(this.rpc, editorsAndDocumentsExt, messageRegistryExt); - const preferenceRegistryExt = new PreferenceRegistryExtImpl(this.rpc, workspaceExt); - const clipboardExt = new ClipboardExt(this.rpc); - const webviewExt = new WebviewsExtImpl(this.rpc, workspaceExt); - const terminalService = new TerminalServiceExtImpl(this.rpc); - const secretsExt = new SecretsExtImpl(this.rpc); - const localizationExt = new LocalizationExtImpl(this.rpc); - this.pluginManager = this.createPluginManager(envExt, terminalService, storageProxy, preferenceRegistryExt, webviewExt, secretsExt, localizationExt, this.rpc); - this.rpc.set(MAIN_RPC_CONTEXT.HOSTED_PLUGIN_MANAGER_EXT, this.pluginManager); - this.rpc.set(MAIN_RPC_CONTEXT.EDITORS_AND_DOCUMENTS_EXT, editorsAndDocumentsExt); - this.rpc.set(MAIN_RPC_CONTEXT.WORKSPACE_EXT, workspaceExt); - this.rpc.set(MAIN_RPC_CONTEXT.PREFERENCE_REGISTRY_EXT, preferenceRegistryExt); - this.rpc.set(MAIN_RPC_CONTEXT.STORAGE_EXT, storageProxy); - this.rpc.set(MAIN_RPC_CONTEXT.WEBVIEWS_EXT, webviewExt); - this.rpc.set(MAIN_RPC_CONTEXT.SECRETS_EXT, secretsExt); - - this.apiFactory = createAPIFactory( - this.rpc, - this.pluginManager, - envExt, - debugExt, - preferenceRegistryExt, - editorsAndDocumentsExt, - workspaceExt, - messageRegistryExt, - clipboardExt, - webviewExt, - localizationExt - ); - connectProxyResolver(workspaceExt, preferenceRegistryExt); + this.pluginManager.setPluginHost(this.createPluginHost()); + + const extInterfaces = this.createExtInterfaces(); + this.registerExtInterfaces(extInterfaces); + + this.apiFactory = this.createAPIFactory(extInterfaces); + + this.loadContainerModule(new ContainerModule(bind => bind(PluginManager).toConstantValue(this.pluginManager))); } async terminate(): Promise { await this.pluginManager.terminate(); } + protected abstract createAPIFactory(extInterfaces: EXT): PAF; + + protected abstract createExtInterfaces(): EXT; + + protected registerExtInterfaces(extInterfaces: EXT): void { + for (const _key in this.extRpc) { + if (Object.hasOwnProperty.call(this.extRpc, _key)) { + const key = _key as keyof ExtInterfaces; + // In case of present undefineds + if (extInterfaces[key]) { + this.rpc.set(this.extRpc[key], extInterfaces[key]); + } + } + } + this.rpc.set(this.extRpc.$pluginManager, this.pluginManager); + } + initContext(contextPath: string, plugin: Plugin): void { const { name, version } = plugin.rawModel; - console.debug('PLUGIN_HOST(' + process.pid + '): initializing(' + name + '@' + version + ' with ' + contextPath + ')'); + console.debug(this.banner, 'initializing(' + name + '@' + version + ' with ' + contextPath + ')'); try { - const backendInit = dynamicRequire<{ doInitialization: BackendInitializationFn }>(contextPath); + type BackendInitFn = (pluginApiFactory: PAF, plugin: Plugin) => void; + const backendInit = dynamicRequire<{ doInitialization: BackendInitFn }>(contextPath); backendInit.doInitialization(this.apiFactory, plugin); } catch (e) { console.error(e); } } - createPluginManager( - envExt: EnvExtImpl, terminalService: TerminalServiceExtImpl, storageProxy: KeyValueStorageProxy, - preferencesManager: PreferenceRegistryExtImpl, webview: WebviewsExtImpl, secretsExt: SecretsExtImpl, localization: LocalizationExt, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - rpc: any - ): PluginManagerExtImpl { + protected getBackendPluginPath(pluginModel: PluginModel): string | undefined { + return pluginModel.entryPoint.backend; + } + + /** + * Create the {@link PluginHost} that is required by my plugin manager ext interface to delegate + * critical behaviour such as loading and initializing plugins to me. + */ + createPluginHost(): PluginHost { const { extensionTestsPath } = process.env; const self = this; - const pluginManager = new PluginManagerExtImpl({ - // eslint-disable-next-line @typescript-eslint/no-explicit-any + return { loadPlugin(plugin: Plugin): any { - console.debug('PLUGIN_HOST(' + process.pid + '): PluginManagerExtImpl/loadPlugin(' + plugin.pluginPath + ')'); + console.debug(self.banner, 'PluginManagerExtImpl/loadPlugin(' + plugin.pluginPath + ')'); // cleaning the cache for all files of that plug-in. // this prevents a memory leak on plugin host restart. See for reference: // https://github.com/eclipse-theia/theia/pull/4931 @@ -125,7 +174,7 @@ export class PluginHostRPC { } }, async init(raw: PluginMetadata[]): Promise<[Plugin[], Plugin[]]> { - console.log('PLUGIN_HOST(' + process.pid + '): PluginManagerExtImpl/init()'); + console.log(self.banner, 'PluginManagerExtImpl/init()'); const result: Plugin[] = []; const foreign: Plugin[] = []; for (const plg of raw) { @@ -146,14 +195,16 @@ export class PluginHostRPC { isUnderDevelopment: !!plg.isUnderDevelopment }); } else { + // Headless and backend plugins are, for now, very similar let backendInitPath = pluginLifecycle.backendInitPath; // if no init path, try to init as regular Theia plugin - if (!backendInitPath) { - backendInitPath = __dirname + '/scanners/backend-init-theia.js'; + if (!backendInitPath && self.backendInitPath) { + backendInitPath = __dirname + self.backendInitPath; } + const pluginPath = self.getBackendPluginPath(pluginModel); const plugin: Plugin = { - pluginPath: pluginModel.entryPoint.backend!, + pluginPath, pluginFolder: pluginModel.packagePath, pluginUri: pluginModel.packageUri, model: pluginModel, @@ -162,30 +213,30 @@ export class PluginHostRPC { isUnderDevelopment: !!plg.isUnderDevelopment }; - self.initContext(backendInitPath, plugin); - + if (backendInitPath) { + self.initContext(backendInitPath, plugin); + } else { + const { name, version } = plugin.rawModel; + console.debug(self.banner, 'initializing(' + name + '@' + version + ' without any default API)'); + } result.push(plugin); } } catch (e) { - console.error(`Failed to initialize ${plg.model.id} plugin.`, e); + console.error(self.banner, `Failed to initialize ${plg.model.id} plugin.`, e); } } return [result, foreign]; }, initExtApi(extApi: ExtPluginApi[]): void { for (const api of extApi) { - if (api.backendInitPath) { - try { - const extApiInit = dynamicRequire<{ provideApi: ExtPluginApiBackendInitializationFn }>(api.backendInitPath); - extApiInit.provideApi(rpc, pluginManager); - } catch (e) { - console.error(e); - } + try { + self.initExtApi(api); + } catch (e) { + console.error(e); } } }, loadTests: extensionTestsPath ? async () => { - /* eslint-disable @typescript-eslint/no-explicit-any */ // Require the test runner via node require from the provided path let testRunner: any; let requireError: Error | undefined; @@ -212,7 +263,115 @@ export class PluginHostRPC { `Path ${extensionTestsPath} does not point to a valid extension test runner.` ); } : undefined - }, envExt, terminalService, storageProxy, secretsExt, preferencesManager, webview, localization, rpc); - return pluginManager; + }; + } + + /** + * Initialize the end of the given provided extension API applicable to the current plugin host. + * Errors should be propagated to the caller. + * + * @param extApi the extension API to initialize, if appropriate + * @throws if any error occurs in initializing the extension API + */ + protected abstract initExtApi(extApi: ExtPluginApi): void; +} + +/** + * The RPC handler for frontend-connection-scoped plugins (Theia and VSCode plugins). + */ +@injectable() +export class PluginHostRPC extends AbstractPluginHostRPC { + @inject(EnvExtImpl) + protected readonly envExt: EnvExtImpl; + + @inject(LocalizationExt) + protected readonly localizationExt: LocalizationExtImpl; + + @inject(KeyValueStorageProxy) + protected readonly keyValueStorageProxy: KeyValueStorageProxy; + + @inject(DebugExtImpl) + protected readonly debugExt: DebugExtImpl; + + @inject(EditorsAndDocumentsExtImpl) + protected readonly editorsAndDocumentsExt: EditorsAndDocumentsExtImpl; + + @inject(MessageRegistryExt) + protected readonly messageRegistryExt: MessageRegistryExt; + + @inject(WorkspaceExtImpl) + protected readonly workspaceExt: WorkspaceExtImpl; + + @inject(PreferenceRegistryExtImpl) + protected readonly preferenceRegistryExt: PreferenceRegistryExtImpl; + + @inject(ClipboardExt) + protected readonly clipboardExt: ClipboardExt; + + @inject(WebviewsExtImpl) + protected readonly webviewExt: WebviewsExtImpl; + + @inject(TerminalServiceExtImpl) + protected readonly terminalServiceExt: TerminalServiceExtImpl; + + @inject(SecretsExtImpl) + protected readonly secretsExt: SecretsExtImpl; + + constructor() { + super('PLUGIN_HOST', '/scanners/backend-init-theia.js', + { + $pluginManager: MAIN_RPC_CONTEXT.HOSTED_PLUGIN_MANAGER_EXT, + editorsAndDocumentsExt: MAIN_RPC_CONTEXT.EDITORS_AND_DOCUMENTS_EXT, + workspaceExt: MAIN_RPC_CONTEXT.WORKSPACE_EXT, + preferenceRegistryExt: MAIN_RPC_CONTEXT.PREFERENCE_REGISTRY_EXT, + storageExt: MAIN_RPC_CONTEXT.STORAGE_EXT, + webviewExt: MAIN_RPC_CONTEXT.WEBVIEWS_EXT, + secretsExt: MAIN_RPC_CONTEXT.SECRETS_EXT + } + ); + } + + protected createExtInterfaces(): ExtInterfaces { + connectProxyResolver(this.workspaceExt, this.preferenceRegistryExt); + return { + envExt: this.envExt, + storageExt: this.keyValueStorageProxy, + debugExt: this.debugExt, + editorsAndDocumentsExt: this.editorsAndDocumentsExt, + messageRegistryExt: this.messageRegistryExt, + workspaceExt: this.workspaceExt, + preferenceRegistryExt: this.preferenceRegistryExt, + clipboardExt: this.clipboardExt, + webviewExt: this.webviewExt, + terminalServiceExt: this.terminalServiceExt, + secretsExt: this.secretsExt, + localizationExt: this.localizationExt + }; + } + + protected createAPIFactory(extInterfaces: ExtInterfaces): PluginAPIFactory { + const { + envExt, debugExt, preferenceRegistryExt, editorsAndDocumentsExt, workspaceExt, + messageRegistryExt, clipboardExt, webviewExt, localizationExt + } = extInterfaces; + return createAPIFactory(this.rpc, this.pluginManager, envExt, debugExt, preferenceRegistryExt, + editorsAndDocumentsExt, workspaceExt, messageRegistryExt, clipboardExt, webviewExt, + localizationExt); + } + + protected initExtApi(extApi: ExtPluginApi): void { + interface PluginExports { + containerModule?: ContainerModule; + provideApi?: ExtPluginApiBackendInitializationFn; + } + if (extApi.backendInitPath) { + const { containerModule, provideApi } = dynamicRequire(extApi.backendInitPath); + if (containerModule) { + this.loadContainerModule(containerModule); + } + if (provideApi) { + provideApi(this.rpc, this.pluginManager); + } + } } } diff --git a/packages/plugin-ext/src/hosted/node/plugin-host.ts b/packages/plugin-ext/src/hosted/node/plugin-host.ts index 0159a153e52fe..cee2ad5b75d5b 100644 --- a/packages/plugin-ext/src/hosted/node/plugin-host.ts +++ b/packages/plugin-ext/src/hosted/node/plugin-host.ts @@ -14,10 +14,11 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** import '@theia/core/shared/reflect-metadata'; -import { ConnectionClosedError, RPCProtocolImpl } from '../../common/rpc-protocol'; +import { Container } from '@theia/core/shared/inversify'; +import { ConnectionClosedError, RPCProtocol } from '../../common/rpc-protocol'; import { ProcessTerminatedMessage, ProcessTerminateMessage } from './hosted-plugin-protocol'; import { PluginHostRPC } from './plugin-host-rpc'; -import { IPCChannel } from '@theia/core/lib/node'; +import pluginHostModule from './plugin-host-module'; console.log('PLUGIN_HOST(' + process.pid + ') starting instance'); @@ -74,8 +75,12 @@ process.on('rejectionHandled', (promise: Promise) => { }); let terminating = false; -const channel = new IPCChannel(); -const rpc = new RPCProtocolImpl(channel); + +const container = new Container(); +container.load(pluginHostModule); + +const rpc: RPCProtocol = container.get(RPCProtocol); +const pluginHostRPC = container.get(PluginHostRPC); process.on('message', async (message: string) => { if (terminating) { @@ -103,6 +108,3 @@ process.on('message', async (message: string) => { console.error(e); } }); - -const pluginHostRPC = new PluginHostRPC(rpc); -pluginHostRPC.initialize(); diff --git a/packages/plugin-ext/src/hosted/node/plugin-reader.ts b/packages/plugin-ext/src/hosted/node/plugin-reader.ts index b137673a3acfd..f90df35151083 100644 --- a/packages/plugin-ext/src/hosted/node/plugin-reader.ts +++ b/packages/plugin-ext/src/hosted/node/plugin-reader.ts @@ -108,6 +108,9 @@ export class HostedPluginReader implements BackendApplicationContribution { if (pluginMetadata.model.entryPoint.backend) { pluginMetadata.model.entryPoint.backend = path.resolve(plugin.packagePath, pluginMetadata.model.entryPoint.backend); } + if (pluginMetadata.model.entryPoint.headless) { + pluginMetadata.model.entryPoint.headless = path.resolve(plugin.packagePath, pluginMetadata.model.entryPoint.headless); + } if (pluginMetadata) { // Add post processor if (this.metadataProcessors) { diff --git a/packages/plugin-ext/src/hosted/node/plugin-service.ts b/packages/plugin-ext/src/hosted/node/plugin-service.ts index 1c67db3e5109d..916009baa0857 100644 --- a/packages/plugin-ext/src/hosted/node/plugin-service.ts +++ b/packages/plugin-ext/src/hosted/node/plugin-service.ts @@ -13,7 +13,7 @@ // // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** -import { injectable, inject, named, postConstruct } from '@theia/core/shared/inversify'; +import { injectable, inject, named, optional, postConstruct } from '@theia/core/shared/inversify'; import { HostedPluginServer, HostedPluginClient, PluginDeployer, GetDeployedPluginsParams, DeployedPlugin, PluginIdentifiers } from '../../common/plugin-protocol'; import { HostedPluginSupport } from './hosted-plugin'; import { ILogger, Disposable, ContributionProvider, DisposableCollection } from '@theia/core'; @@ -23,6 +23,14 @@ import { PluginDeployerImpl } from '../../main/node/plugin-deployer-impl'; import { HostedPluginLocalizationService } from './hosted-plugin-localization-service'; import { PluginUninstallationManager } from '../../main/node/plugin-uninstallation-manager'; +export const BackendPluginHostableFilter = Symbol('BackendPluginHostableFilter'); +/** + * A filter matching backend plugins that are hostable in my plugin host process. + * Only if at least one backend plugin is deployed that matches my filter will I + * start the host process. + */ +export type BackendPluginHostableFilter = (plugin: DeployedPlugin) => boolean; + @injectable() export class HostedPluginServerImpl implements HostedPluginServer { @inject(ILogger) @@ -43,6 +51,10 @@ export class HostedPluginServerImpl implements HostedPluginServer { @inject(PluginUninstallationManager) protected readonly uninstallationManager: PluginUninstallationManager; + @inject(BackendPluginHostableFilter) + @optional() + protected backendPluginHostableFilter: BackendPluginHostableFilter; + protected client: HostedPluginClient | undefined; protected toDispose = new DisposableCollection(); @@ -63,6 +75,10 @@ export class HostedPluginServerImpl implements HostedPluginServer { @postConstruct() protected init(): void { + if (!this.backendPluginHostableFilter) { + this.backendPluginHostableFilter = () => true; + } + this.toDispose.pushAll([ this.pluginDeployer.onDidDeploy(() => this.client?.onDidDeploy()), this.uninstallationManager.onDidChangeUninstalledPlugins(currentUninstalled => { @@ -90,8 +106,9 @@ export class HostedPluginServerImpl implements HostedPluginServer { } async getDeployedPluginIds(): Promise { - const backendMetadata = await this.deployerHandler.getDeployedBackendPluginIds(); - if (backendMetadata.length > 0) { + const backendPlugins = (await this.deployerHandler.getDeployedBackendPlugins()) + .filter(this.backendPluginHostableFilter); + if (backendPlugins.length > 0) { this.hostedPlugin.runPluginServer(); } const plugins = new Set(); @@ -103,7 +120,7 @@ export class HostedPluginServerImpl implements HostedPluginServer { } }; addIds(await this.deployerHandler.getDeployedFrontendPluginIds()); - addIds(backendMetadata); + addIds(await this.deployerHandler.getDeployedBackendPluginIds()); addIds(await this.hostedPlugin.getExtraDeployedPluginIds()); return Array.from(plugins); } 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 6f05afe4f8d1c..af527a5e283b5 100644 --- a/packages/plugin-ext/src/hosted/node/scanners/scanner-theia.ts +++ b/packages/plugin-ext/src/hosted/node/scanners/scanner-theia.ts @@ -62,7 +62,9 @@ import { Translation, PluginIdentifiers, TerminalProfile, - PluginIconContribution + PluginIconContribution, + PluginEntryPoint, + PluginPackageContribution } from '../../../common/plugin-protocol'; import { promises as fs } from 'fs'; import * as path from 'path'; @@ -87,17 +89,19 @@ function getFileExtension(filePath: string): string { return index === -1 ? '' : filePath.substring(index + 1); } -@injectable() -export class TheiaPluginScanner implements PluginScanner { +type PluginPackageWithContributes = PluginPackage & { contributes: PluginPackageContribution }; - private readonly _apiType: PluginEngine = 'theiaPlugin'; +@injectable() +export abstract class AbstractPluginScanner implements PluginScanner { @inject(GrammarsReader) - private readonly grammarsReader: GrammarsReader; + protected readonly grammarsReader: GrammarsReader; @inject(PluginUriFactory) protected readonly pluginUriFactory: PluginUriFactory; + constructor(private readonly _apiType: PluginEngine, private readonly _backendInitPath?: string) { } + get apiType(): PluginEngine { return this._apiType; } @@ -119,22 +123,25 @@ export class TheiaPluginScanner implements PluginScanner { type: this._apiType, version: plugin.engines[this._apiType] }, - entryPoint: { - frontend: plugin.theiaPlugin!.frontend, - backend: plugin.theiaPlugin!.backend - } + entryPoint: this.getEntryPoint(plugin) }; return result; } + protected abstract getEntryPoint(plugin: PluginPackage): PluginEntryPoint; + getLifecycle(plugin: PluginPackage): PluginLifecycle { - return { + const result: PluginLifecycle = { startMethod: 'start', stopMethod: 'stop', frontendModuleName: buildFrontendModuleName(plugin), - - backendInitPath: path.join(__dirname, 'backend-init-theia') }; + + if (this._backendInitPath) { + result.backendInitPath = path.join(__dirname, this._backendInitPath); + } + + return result; } getDependencies(rawPlugin: PluginPackage): Map | undefined { @@ -155,6 +162,33 @@ export class TheiaPluginScanner implements PluginScanner { return contributions; } + return this.readContributions(rawPlugin as PluginPackageWithContributes, contributions); + } + + protected async readContributions(rawPlugin: PluginPackageWithContributes, contributions: PluginContribution): Promise { + return contributions; + } + +} + +@injectable() +export class TheiaPluginScanner extends AbstractPluginScanner { + constructor() { + super('theiaPlugin', 'backend-init-theia'); + } + + protected getEntryPoint(plugin: PluginPackage): PluginEntryPoint { + const result: PluginEntryPoint = { + frontend: plugin.theiaPlugin!.frontend, + backend: plugin.theiaPlugin!.backend + }; + if (plugin.theiaPlugin?.headless) { + result.headless = plugin.theiaPlugin.headless; + } + return result; + } + + protected override async readContributions(rawPlugin: PluginPackageWithContributes, contributions: PluginContribution): Promise { try { if (rawPlugin.contributes.configuration) { const configurations = Array.isArray(rawPlugin.contributes.configuration) ? rawPlugin.contributes.configuration : [rawPlugin.contributes.configuration]; diff --git a/packages/plugin-ext/src/main/browser/env-main.ts b/packages/plugin-ext/src/main/browser/env-main.ts index af9a9fa7335c9..12254b3fc7dd7 100644 --- a/packages/plugin-ext/src/main/browser/env-main.ts +++ b/packages/plugin-ext/src/main/browser/env-main.ts @@ -14,35 +14,8 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** -import { interfaces } from '@theia/core/shared/inversify'; -import { EnvVariablesServer } from '@theia/core/lib/common/env-variables'; -import { RPCProtocol } from '../../common/rpc-protocol'; -import { EnvMain } from '../../common/plugin-api-rpc'; import { QueryParameters } from '../../common/env'; -import { isWindows, isOSX } from '@theia/core'; -import { OperatingSystem } from '../../plugin/types-impl'; - -export class EnvMainImpl implements EnvMain { - private envVariableServer: EnvVariablesServer; - - constructor(rpc: RPCProtocol, container: interfaces.Container) { - this.envVariableServer = container.get(EnvVariablesServer); - } - - $getEnvVariable(envVarName: string): Promise { - return this.envVariableServer.getValue(envVarName).then(result => result ? result.value : undefined); - } - - async $getClientOperatingSystem(): Promise { - if (isWindows) { - return OperatingSystem.Windows; - } - if (isOSX) { - return OperatingSystem.OSX; - } - return OperatingSystem.Linux; - } -} +export { EnvMainImpl } from '../common/env-main'; /** * Returns query parameters from current page. diff --git a/packages/plugin-ext/src/main/browser/message-registry-main.ts b/packages/plugin-ext/src/main/browser/message-registry-main.ts index 5deaf5c747550..53af9a6c37dad 100644 --- a/packages/plugin-ext/src/main/browser/message-registry-main.ts +++ b/packages/plugin-ext/src/main/browser/message-registry-main.ts @@ -15,26 +15,21 @@ // ***************************************************************************** import { interfaces } from '@theia/core/shared/inversify'; -import { MessageService } from '@theia/core/lib/common/message-service'; -import { MessageRegistryMain, MainMessageType, MainMessageOptions, MainMessageItem } from '../../common/plugin-api-rpc'; +import { MainMessageType, MainMessageOptions, MainMessageItem } from '../../common/plugin-api-rpc'; import { ModalNotification, MessageType } from './dialogs/modal-notification'; +import { BasicMessageRegistryMainImpl } from '../common/basic-message-registry-main'; -export class MessageRegistryMainImpl implements MessageRegistryMain { - private readonly messageService: MessageService; - +/** + * Message registry implementation that adds support for the model option via dialog in the browser. + */ +export class MessageRegistryMainImpl extends BasicMessageRegistryMainImpl { constructor(container: interfaces.Container) { - this.messageService = container.get(MessageService); + super(container); } - async $showMessage(type: MainMessageType, message: string, options: MainMessageOptions, actions: MainMessageItem[]): Promise { - const action = await this.doShowMessage(type, message, options, actions); - const handle = action - ? actions.map(a => a.title).indexOf(action) - : undefined; - return handle === undefined && options.modal ? options.onCloseActionHandle : handle; - } + protected override async doShowMessage(type: MainMessageType, message: string, + options: MainMessageOptions, actions: MainMessageItem[]): Promise { - protected async doShowMessage(type: MainMessageType, message: string, options: MainMessageOptions, actions: MainMessageItem[]): Promise { if (options.modal) { const messageType = type === MainMessageType.Error ? MessageType.Error : type === MainMessageType.Warning ? MessageType.Warning : @@ -42,15 +37,7 @@ export class MessageRegistryMainImpl implements MessageRegistryMain { const modalNotification = new ModalNotification(); return modalNotification.showDialog(messageType, message, options, actions); } - switch (type) { - case MainMessageType.Info: - return this.messageService.info(message, ...actions.map(a => a.title)); - case MainMessageType.Warning: - return this.messageService.warn(message, ...actions.map(a => a.title)); - case MainMessageType.Error: - return this.messageService.error(message, ...actions.map(a => a.title)); - } - throw new Error(`Message type '${type}' is not supported yet!`); + return super.doShowMessage(type, message, options, actions); } } diff --git a/packages/plugin-ext/src/main/browser/notification-main.ts b/packages/plugin-ext/src/main/browser/notification-main.ts index 90ab72eb7203c..eb3379f866099 100644 --- a/packages/plugin-ext/src/main/browser/notification-main.ts +++ b/packages/plugin-ext/src/main/browser/notification-main.ts @@ -14,73 +14,13 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** -import { NotificationExt, NotificationMain, MAIN_RPC_CONTEXT } from '../../common'; -import { ProgressService, Progress, ProgressMessage } from '@theia/core/lib/common'; +import { MAIN_RPC_CONTEXT } from '../../common'; import { interfaces } from '@theia/core/shared/inversify'; import { RPCProtocol } from '../../common/rpc-protocol'; -import { Disposable, DisposableCollection } from '@theia/core/lib/common/disposable'; - -export class NotificationMainImpl implements NotificationMain, Disposable { - private readonly progressService: ProgressService; - private readonly progressMap = new Map(); - private readonly progress2Work = new Map(); - private readonly proxy: NotificationExt; - - protected readonly toDispose = new DisposableCollection( - Disposable.create(() => { /* mark as not disposed */ }) - ); +import { BasicNotificationMainImpl } from '../common/basic-notification-main'; +export class NotificationMainImpl extends BasicNotificationMainImpl { constructor(rpc: RPCProtocol, container: interfaces.Container) { - this.progressService = container.get(ProgressService); - this.proxy = rpc.getProxy(MAIN_RPC_CONTEXT.NOTIFICATION_EXT); - } - - dispose(): void { - this.toDispose.dispose(); - } - - async $startProgress(options: NotificationMain.StartProgressOptions): Promise { - const onDidCancel = () => { - // If the map does not contain current id, it has already stopped and should not be cancelled - if (this.progressMap.has(id)) { - this.proxy.$acceptProgressCanceled(id); - } - }; - - const progressMessage = this.mapOptions(options); - const progress = await this.progressService.showProgress(progressMessage, onDidCancel); - const id = progress.id; - this.progressMap.set(id, progress); - this.progress2Work.set(id, 0); - if (this.toDispose.disposed) { - this.$stopProgress(id); - } else { - this.toDispose.push(Disposable.create(() => this.$stopProgress(id))); - } - return id; - } - protected mapOptions(options: NotificationMain.StartProgressOptions): ProgressMessage { - const { title, location, cancellable } = options; - return { text: title, options: { location, cancelable: cancellable } }; - } - - $stopProgress(id: string): void { - const progress = this.progressMap.get(id); - - if (progress) { - this.progressMap.delete(id); - this.progress2Work.delete(id); - progress.cancel(); - } - } - - $updateProgress(id: string, item: NotificationMain.ProgressReport): void { - const progress = this.progressMap.get(id); - if (!progress) { - return; - } - const done = Math.min((this.progress2Work.get(id) || 0) + (item.increment || 0), 100); - this.progress2Work.set(id, done); - progress.report({ message: item.message, work: done ? { done, total: 100 } : undefined }); + super(rpc, container, MAIN_RPC_CONTEXT.NOTIFICATION_EXT); } } diff --git a/packages/plugin-ext/src/main/common/basic-message-registry-main.ts b/packages/plugin-ext/src/main/common/basic-message-registry-main.ts new file mode 100644 index 0000000000000..d9e60d413c117 --- /dev/null +++ b/packages/plugin-ext/src/main/common/basic-message-registry-main.ts @@ -0,0 +1,53 @@ +// ***************************************************************************** +// Copyright (C) 2018 Red Hat, Inc. 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-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { interfaces } from '@theia/core/shared/inversify'; +import { MessageService } from '@theia/core/lib/common/message-service'; +import { MessageRegistryMain, MainMessageType, MainMessageOptions, MainMessageItem } from '../../common/plugin-api-rpc'; + +/** + * A basic implementation of the message registry that does not support the modal option + * as that requires an UI. + */ +export class BasicMessageRegistryMainImpl implements MessageRegistryMain { + protected readonly messageService: MessageService; + + constructor(container: interfaces.Container) { + this.messageService = container.get(MessageService); + } + + async $showMessage(type: MainMessageType, message: string, options: MainMessageOptions, actions: MainMessageItem[]): Promise { + const action = await this.doShowMessage(type, message, options, actions); + const handle = action + ? actions.map(a => a.title).indexOf(action) + : undefined; + return handle === undefined && options.modal ? options.onCloseActionHandle : handle; + } + + protected async doShowMessage(type: MainMessageType, message: string, options: MainMessageOptions, actions: MainMessageItem[]): Promise { + // Modal notifications are not supported in this context + switch (type) { + case MainMessageType.Info: + return this.messageService.info(message, ...actions.map(a => a.title)); + case MainMessageType.Warning: + return this.messageService.warn(message, ...actions.map(a => a.title)); + case MainMessageType.Error: + return this.messageService.error(message, ...actions.map(a => a.title)); + } + throw new Error(`Message type '${type}' is not supported yet!`); + } + +} diff --git a/packages/plugin-ext/src/main/common/basic-notification-main.ts b/packages/plugin-ext/src/main/common/basic-notification-main.ts new file mode 100644 index 0000000000000..4cb73d47f0a2d --- /dev/null +++ b/packages/plugin-ext/src/main/common/basic-notification-main.ts @@ -0,0 +1,86 @@ +// ***************************************************************************** +// Copyright (C) 2018 Red Hat, Inc. 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-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { NotificationExt, NotificationMain } from '../../common'; +import { ProgressService, Progress, ProgressMessage } from '@theia/core/lib/common'; +import { interfaces } from '@theia/core/shared/inversify'; +import { ProxyIdentifier, RPCProtocol } from '../../common/rpc-protocol'; +import { Disposable, DisposableCollection } from '@theia/core/lib/common/disposable'; + +export class BasicNotificationMainImpl implements NotificationMain, Disposable { + protected readonly progressService: ProgressService; + protected readonly progressMap = new Map(); + protected readonly progress2Work = new Map(); + protected readonly proxy: NotificationExt; + + protected readonly toDispose = new DisposableCollection( + Disposable.create(() => { /* mark as not disposed */ }) + ); + + constructor(rpc: RPCProtocol, container: interfaces.Container, extIdentifier: ProxyIdentifier) { + this.progressService = container.get(ProgressService); + this.proxy = rpc.getProxy(extIdentifier); + } + + dispose(): void { + this.toDispose.dispose(); + } + + async $startProgress(options: NotificationMain.StartProgressOptions): Promise { + const onDidCancel = () => { + // If the map does not contain current id, it has already stopped and should not be cancelled + if (this.progressMap.has(id)) { + this.proxy.$acceptProgressCanceled(id); + } + }; + + const progressMessage = this.mapOptions(options); + const progress = await this.progressService.showProgress(progressMessage, onDidCancel); + const id = progress.id; + this.progressMap.set(id, progress); + this.progress2Work.set(id, 0); + if (this.toDispose.disposed) { + this.$stopProgress(id); + } else { + this.toDispose.push(Disposable.create(() => this.$stopProgress(id))); + } + return id; + } + protected mapOptions(options: NotificationMain.StartProgressOptions): ProgressMessage { + const { title, location, cancellable } = options; + return { text: title, options: { location, cancelable: cancellable } }; + } + + $stopProgress(id: string): void { + const progress = this.progressMap.get(id); + + if (progress) { + this.progressMap.delete(id); + this.progress2Work.delete(id); + progress.cancel(); + } + } + + $updateProgress(id: string, item: NotificationMain.ProgressReport): void { + const progress = this.progressMap.get(id); + if (!progress) { + return; + } + const done = Math.min((this.progress2Work.get(id) || 0) + (item.increment || 0), 100); + this.progress2Work.set(id, done); + progress.report({ message: item.message, work: done ? { done, total: 100 } : undefined }); + } +} diff --git a/packages/plugin-ext/src/main/common/env-main.ts b/packages/plugin-ext/src/main/common/env-main.ts new file mode 100644 index 0000000000000..a5014bd563208 --- /dev/null +++ b/packages/plugin-ext/src/main/common/env-main.ts @@ -0,0 +1,44 @@ +// ***************************************************************************** +// Copyright (C) 2018 Red Hat, Inc. 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-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { interfaces } from '@theia/core/shared/inversify'; +import { EnvVariablesServer } from '@theia/core/lib/common/env-variables'; +import { RPCProtocol } from '../../common/rpc-protocol'; +import { EnvMain } from '../../common/plugin-api-rpc'; +import { isWindows, isOSX } from '@theia/core'; +import { OperatingSystem } from '../../plugin/types-impl'; + +export class EnvMainImpl implements EnvMain { + private envVariableServer: EnvVariablesServer; + + constructor(rpc: RPCProtocol, container: interfaces.Container) { + this.envVariableServer = container.get(EnvVariablesServer); + } + + $getEnvVariable(envVarName: string): Promise { + return this.envVariableServer.getValue(envVarName).then(result => result ? result.value : undefined); + } + + async $getClientOperatingSystem(): Promise { + if (isWindows) { + return OperatingSystem.Windows; + } + if (isOSX) { + return OperatingSystem.OSX; + } + return OperatingSystem.Linux; + } +} 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 a33fa57a72f23..84c4e7025ddeb 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 @@ -28,7 +28,7 @@ import { PluginCliContribution } from '../plugin-cli-contribution'; import { getTempDirPathAsync } from '../temp-dir-util'; @injectable() -export class PluginTheiaDirectoryHandler implements PluginDeployerDirectoryHandler { +export abstract class AbstractPluginDirectoryHandler implements PluginDeployerDirectoryHandler { protected readonly deploymentDirectory: Deferred; @@ -42,15 +42,20 @@ export class PluginTheiaDirectoryHandler implements PluginDeployerDirectoryHandl async accept(resolvedPlugin: PluginDeployerEntry): Promise { - console.debug('PluginTheiaDirectoryHandler: accepting plugin with path', resolvedPlugin.path()); + console.debug(`Plugin directory handler: accepting plugin with path ${resolvedPlugin.path()}`); // handle only directories if (await resolvedPlugin.isFile()) { return false; } + // Was this directory unpacked from an NPM tarball? + const wasTarball = resolvedPlugin.originalPath().endsWith('.tgz'); + const rootPath = resolvedPlugin.path(); + const basePath = wasTarball ? path.resolve(rootPath, 'package') : rootPath; + // is there a package.json ? - const packageJsonPath = path.resolve(resolvedPlugin.path(), 'package.json'); + const packageJsonPath = path.resolve(basePath, 'package.json'); try { let packageJson = resolvedPlugin.getValue('package.json'); @@ -60,26 +65,20 @@ export class PluginTheiaDirectoryHandler implements PluginDeployerDirectoryHandl resolvedPlugin.storeValue('package.json', packageJson); } - if (packageJson?.engines?.theiaPlugin) { + if (this.acceptManifest(packageJson)) { + if (wasTarball) { + resolvedPlugin.updatePath(basePath); + resolvedPlugin.rootPath = rootPath; + } return true; } } catch { /* Failed to read file. Fall through. */ } return false; } - async handle(context: PluginDeployerDirectoryHandlerContext): Promise { - await this.copyDirectory(context); - const types: PluginDeployerEntryType[] = []; - const packageJson = context.pluginEntry().getValue('package.json'); - if (packageJson.theiaPlugin && packageJson.theiaPlugin.backend) { - types.push(PluginDeployerEntryType.BACKEND); - } - if (packageJson.theiaPlugin && packageJson.theiaPlugin.frontend) { - types.push(PluginDeployerEntryType.FRONTEND); - } + protected abstract acceptManifest(plugin: PluginPackage): boolean; - context.pluginEntry().accept(...types); - } + abstract handle(context: PluginDeployerDirectoryHandlerContext): Promise; protected async copyDirectory(context: PluginDeployerDirectoryHandlerContext): Promise { if (this.pluginCli.copyUncompressedPlugins() && context.pluginEntry().type === PluginType.User) { @@ -112,4 +111,27 @@ export class PluginTheiaDirectoryHandler implements PluginDeployerDirectoryHandl const deploymentDirectory = await this.deploymentDirectory.promise; return FileUri.fsPath(deploymentDirectory.resolve(filenamify(context.pluginEntry().id(), { replacement: '_' }))); } + +} + +@injectable() +export class PluginTheiaDirectoryHandler extends AbstractPluginDirectoryHandler { + + protected acceptManifest(plugin: PluginPackage): boolean { + return plugin?.engines?.theiaPlugin !== undefined; + } + + async handle(context: PluginDeployerDirectoryHandlerContext): Promise { + await this.copyDirectory(context); + const types: PluginDeployerEntryType[] = []; + const packageJson = context.pluginEntry().getValue('package.json'); + if (packageJson.theiaPlugin && packageJson.theiaPlugin.backend) { + types.push(PluginDeployerEntryType.BACKEND); + } + if (packageJson.theiaPlugin && packageJson.theiaPlugin.frontend) { + types.push(PluginDeployerEntryType.FRONTEND); + } + + context.pluginEntry().accept(...types); + } } 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 1eb032c397741..8c60671b78b88 100644 --- a/packages/plugin-ext/src/main/node/plugin-deployer-impl.ts +++ b/packages/plugin-ext/src/main/node/plugin-deployer-impl.ts @@ -271,20 +271,24 @@ export class PluginDeployerImpl implements PluginDeployer { const acceptedPlugins = pluginsToDeploy.filter(pluginDeployerEntry => pluginDeployerEntry.isAccepted()); const acceptedFrontendPlugins = pluginsToDeploy.filter(pluginDeployerEntry => pluginDeployerEntry.isAccepted(PluginDeployerEntryType.FRONTEND)); const acceptedBackendPlugins = pluginsToDeploy.filter(pluginDeployerEntry => pluginDeployerEntry.isAccepted(PluginDeployerEntryType.BACKEND)); + const acceptedHeadlessPlugins = pluginsToDeploy.filter(pluginDeployerEntry => pluginDeployerEntry.isAccepted(PluginDeployerEntryType.HEADLESS)); this.logger.debug('the accepted plugins are', acceptedPlugins); this.logger.debug('the acceptedFrontendPlugins plugins are', acceptedFrontendPlugins); this.logger.debug('the acceptedBackendPlugins plugins are', acceptedBackendPlugins); + this.logger.debug('the acceptedHeadlessPlugins plugins are', acceptedHeadlessPlugins); acceptedPlugins.forEach(plugin => { this.logger.debug('will deploy plugin', plugin.id(), 'with changes', JSON.stringify(plugin.getChanges()), 'and this plugin has been resolved by', plugin.resolvedBy()); }); // local path to launch - const pluginPaths = acceptedBackendPlugins.map(pluginEntry => pluginEntry.path()); + const pluginPaths = [...acceptedBackendPlugins, ...acceptedHeadlessPlugins].map(pluginEntry => pluginEntry.path()); this.logger.debug('local path to deploy on remote instance', pluginPaths); const deployments = await Promise.all([ + // headless plugins are deployed like backend plugins + this.pluginDeployerHandler.deployBackendPlugins(acceptedHeadlessPlugins), // start the backend plugins this.pluginDeployerHandler.deployBackendPlugins(acceptedBackendPlugins), this.pluginDeployerHandler.deployFrontendPlugins(acceptedFrontendPlugins) diff --git a/packages/plugin-ext/src/plugin/clipboard-ext.ts b/packages/plugin-ext/src/plugin/clipboard-ext.ts index 363f83085be88..65ed8e7d39af7 100644 --- a/packages/plugin-ext/src/plugin/clipboard-ext.ts +++ b/packages/plugin-ext/src/plugin/clipboard-ext.ts @@ -15,15 +15,21 @@ // ***************************************************************************** import * as theia from '@theia/plugin'; +import { inject, injectable, postConstruct } from '@theia/core/shared/inversify'; import { RPCProtocol } from '../common/rpc-protocol'; import { PLUGIN_RPC_CONTEXT, ClipboardMain } from '../common'; +@injectable() export class ClipboardExt implements theia.Clipboard { - protected readonly proxy: ClipboardMain; + @inject(RPCProtocol) + protected readonly rpc: RPCProtocol; - constructor(rpc: RPCProtocol) { - this.proxy = rpc.getProxy(PLUGIN_RPC_CONTEXT.CLIPBOARD_MAIN); + protected proxy: ClipboardMain; + + @postConstruct() + initialize(): void { + this.proxy = this.rpc.getProxy(PLUGIN_RPC_CONTEXT.CLIPBOARD_MAIN); } readText(): Promise { diff --git a/packages/plugin-ext/src/plugin/debug/debug-ext.ts b/packages/plugin-ext/src/plugin/debug/debug-ext.ts index bc896a9be928a..d982385f30b82 100644 --- a/packages/plugin-ext/src/plugin/debug/debug-ext.ts +++ b/packages/plugin-ext/src/plugin/debug/debug-ext.ts @@ -13,6 +13,7 @@ // // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** +import { inject, injectable, postConstruct } from '@theia/core/shared/inversify'; import { Emitter } from '@theia/core/lib/common/event'; import { Path } from '@theia/core/lib/common/path'; import * as theia from '@theia/plugin'; @@ -43,7 +44,11 @@ interface ConfigurationProviderRecord { /* eslint-disable @typescript-eslint/no-explicit-any */ +@injectable() export class DebugExtImpl implements DebugExt { + @inject(RPCProtocol) + protected readonly rpc: RPCProtocol; + // debug sessions by sessionId private sessions = new Map(); private configurationProviderHandleGenerator: number; @@ -83,8 +88,7 @@ export class DebugExtImpl implements DebugExt { return [...this._breakpoints.values()]; } - constructor(rpc: RPCProtocol) { - this.proxy = rpc.getProxy(Ext.DEBUG_MAIN); + constructor() { this.activeDebugConsole = { append: (value: string) => this.proxy.$appendToDebugConsole(value), appendLine: (value: string) => this.proxy.$appendLineToDebugConsole(value) @@ -93,6 +97,11 @@ export class DebugExtImpl implements DebugExt { this.configurationProviders = []; } + @postConstruct() + initialize(): void { + this.proxy = this.rpc.getProxy(Ext.DEBUG_MAIN); + } + /** * Sets dependencies. */ diff --git a/packages/plugin-ext/src/plugin/editors-and-documents.ts b/packages/plugin-ext/src/plugin/editors-and-documents.ts index 4d3255e9f5507..d5a98a9863fa3 100644 --- a/packages/plugin-ext/src/plugin/editors-and-documents.ts +++ b/packages/plugin-ext/src/plugin/editors-and-documents.ts @@ -14,6 +14,7 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** +import { inject, injectable } from '@theia/core/shared/inversify'; import { EditorsAndDocumentsExt, EditorsAndDocumentsDelta, PLUGIN_RPC_CONTEXT } from '../common/plugin-api-rpc'; import { TextEditorExt } from './text-editor'; import { RPCProtocol } from '../common/rpc-protocol'; @@ -24,7 +25,11 @@ import * as Converter from './type-converters'; import { dispose } from '../common/disposable-util'; import { URI } from './types-impl'; +@injectable() export class EditorsAndDocumentsExtImpl implements EditorsAndDocumentsExt { + @inject(RPCProtocol) + protected readonly rpc: RPCProtocol; + private activeEditorId: string | null = null; private readonly _onDidAddDocuments = new Emitter(); @@ -40,9 +45,6 @@ export class EditorsAndDocumentsExtImpl implements EditorsAndDocumentsExt { private readonly documents = new Map(); private readonly editors = new Map(); - constructor(private readonly rpc: RPCProtocol) { - } - async $acceptEditorsAndDocumentsDelta(delta: EditorsAndDocumentsDelta): Promise { const removedDocuments = new Array(); const addedDocuments = new Array(); diff --git a/packages/plugin-ext/src/plugin/env.ts b/packages/plugin-ext/src/plugin/env.ts index 8fe8494a49c47..3d283bedb9561 100644 --- a/packages/plugin-ext/src/plugin/env.ts +++ b/packages/plugin-ext/src/plugin/env.ts @@ -14,13 +14,18 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** +import { inject, injectable, postConstruct } from '@theia/core/shared/inversify'; import * as theia from '@theia/plugin'; import { RPCProtocol } from '../common/rpc-protocol'; import { EnvMain, PLUGIN_RPC_CONTEXT } from '../common/plugin-api-rpc'; import { QueryParameters } from '../common/env'; import { v4 } from 'uuid'; +@injectable() export abstract class EnvExtImpl { + @inject(RPCProtocol) + protected readonly rpc: RPCProtocol; + private proxy: EnvMain; private queryParameters: QueryParameters; private lang: string; @@ -31,13 +36,17 @@ export abstract class EnvExtImpl { private host: string; private _remoteName: string | undefined; - constructor(rpc: RPCProtocol) { - this.proxy = rpc.getProxy(PLUGIN_RPC_CONTEXT.ENV_MAIN); + constructor() { this.envSessionId = v4(); this.envMachineId = v4(); this._remoteName = undefined; } + @postConstruct() + initialize(): void { + this.proxy = this.rpc.getProxy(PLUGIN_RPC_CONTEXT.ENV_MAIN); + } + getEnvVariable(envVarName: string): Promise { return this.proxy.$getEnvVariable(envVarName).then(x => { if (x === null) { diff --git a/packages/plugin-ext/src/plugin/localization-ext.ts b/packages/plugin-ext/src/plugin/localization-ext.ts index 0e93969a1ec95..1a70927108b70 100644 --- a/packages/plugin-ext/src/plugin/localization-ext.ts +++ b/packages/plugin-ext/src/plugin/localization-ext.ts @@ -16,6 +16,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import { inject, injectable, postConstruct } from '@theia/core/shared/inversify'; import { nls } from '@theia/core'; import { Localization } from '@theia/core/lib/common/i18n/localization'; import { LocalizationExt, LocalizationMain, Plugin, PLUGIN_RPC_CONTEXT, StringDetails } from '../common'; @@ -23,15 +24,19 @@ import { LanguagePackBundle } from '../common/language-pack-service'; import { RPCProtocol } from '../common/rpc-protocol'; import { URI } from './types-impl'; +@injectable() export class LocalizationExtImpl implements LocalizationExt { + @inject(RPCProtocol) + protected readonly rpc: RPCProtocol; - private readonly _proxy: LocalizationMain; + private _proxy: LocalizationMain; private currentLanguage?: string; private isDefaultLanguage = true; private readonly bundleCache = new Map(); - constructor(rpc: RPCProtocol) { - this._proxy = rpc.getProxy(PLUGIN_RPC_CONTEXT.LOCALIZATION_MAIN); + @postConstruct() + initialize(): void { + this._proxy = this.rpc.getProxy(PLUGIN_RPC_CONTEXT.LOCALIZATION_MAIN); } translateMessage(pluginId: string, details: StringDetails): string { diff --git a/packages/plugin-ext/src/plugin/message-registry.ts b/packages/plugin-ext/src/plugin/message-registry.ts index 016f518dec9c3..c5fb851c7f6fc 100644 --- a/packages/plugin-ext/src/plugin/message-registry.ts +++ b/packages/plugin-ext/src/plugin/message-registry.ts @@ -13,18 +13,23 @@ // // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** +import { inject, injectable, postConstruct } from '@theia/core/shared/inversify'; import { PLUGIN_RPC_CONTEXT as Ext, MessageRegistryMain, MainMessageOptions, MainMessageType } from '../common/plugin-api-rpc'; import { RPCProtocol } from '../common/rpc-protocol'; import { MessageItem, MessageOptions } from '@theia/plugin'; +@injectable() export class MessageRegistryExt { + @inject(RPCProtocol) + protected readonly rpc: RPCProtocol; private proxy: MessageRegistryMain; - constructor(rpc: RPCProtocol) { - this.proxy = rpc.getProxy(Ext.MESSAGE_REGISTRY_MAIN); + @postConstruct() + initialize(): void { + this.proxy = this.rpc.getProxy(Ext.MESSAGE_REGISTRY_MAIN); } async showMessage(type: MainMessageType, message: string, diff --git a/packages/plugin-ext/src/plugin/node/debug/debug.spec.ts b/packages/plugin-ext/src/plugin/node/debug/debug.spec.ts index 16b2c330ac252..25d28c1be2097 100644 --- a/packages/plugin-ext/src/plugin/node/debug/debug.spec.ts +++ b/packages/plugin-ext/src/plugin/node/debug/debug.spec.ts @@ -13,6 +13,7 @@ * * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 ********************************************************************************/ +import { Container } from '@theia/core/shared/inversify'; import { DebugSession } from '@theia/plugin'; import * as chai from 'chai'; import { ProxyIdentifier, RPCProtocol } from '../../../common/rpc-protocol'; @@ -37,7 +38,10 @@ describe('Debug API', () => { } }; - const debug = new DebugExtImpl(mockRPCProtocol); + const container = new Container(); + container.bind(RPCProtocol).toConstantValue(mockRPCProtocol); + container.bind(DebugExtImpl).toSelf().inSingletonScope(); + const debug = container.get(DebugExtImpl); it('should use sourceReference, path and sessionId', () => { const source = { diff --git a/packages/plugin-ext/src/plugin/node/env-node-ext.ts b/packages/plugin-ext/src/plugin/node/env-node-ext.ts index 65924f11b95e6..2ad86064f13cf 100644 --- a/packages/plugin-ext/src/plugin/node/env-node-ext.ts +++ b/packages/plugin-ext/src/plugin/node/env-node-ext.ts @@ -14,9 +14,9 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** +import { injectable } from '@theia/core/shared/inversify'; import * as mac from 'macaddress'; import { EnvExtImpl } from '../env'; -import { RPCProtocol } from '../../common/rpc-protocol'; import { createHash } from 'crypto'; import { v4 } from 'uuid'; import fs = require('fs'); @@ -25,13 +25,15 @@ import fs = require('fs'); * Provides machineId using mac address. It's only possible on node side * Extending the common class */ +@injectable() export class EnvNodeExtImpl extends EnvExtImpl { private macMachineId: string; private _isNewAppInstall: boolean; - constructor(rpc: RPCProtocol) { - super(rpc); + constructor() { + super(); + mac.one((err, macAddress) => { if (err) { this.macMachineId = v4(); diff --git a/packages/plugin-ext/src/plugin/node/plugin-container-module.ts b/packages/plugin-ext/src/plugin/node/plugin-container-module.ts new file mode 100644 index 0000000000000..e23285d1da569 --- /dev/null +++ b/packages/plugin-ext/src/plugin/node/plugin-container-module.ts @@ -0,0 +1,165 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource 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-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { interfaces, ContainerModule } from '@theia/core/shared/inversify'; +import { Plugin, PluginManager, emptyPlugin } from '../../common'; + +export type ApiFactory = (plugin: Plugin) => T; + +/** + * Bind a service identifier for the factory function creating API objects of + * type `T` for a client plugin to a class providing a `call()` method that + * implements that factory function. + * + * @template T the API object type that the factory creates + * @param serviceIdentifier the injection key identifying the API factory function + * @param factoryClass the class implementing the API factory function via its `call()` method + */ +export type BindApiFactory = ( + apiModuleName: string, + serviceIdentifier: interfaces.ServiceIdentifier>, + factoryClass: new () => { createApi: ApiFactory}) => void; + +/** + * An analogue of the callback function in the constructor of the Inversify + * `ContainerModule` providing a registry that, in addition to the standard + * binding-related functions, includes a custom function for binding an + * API factory. + */ +export type PluginContainerModuleCallBack = (registry: { + bind: interfaces.Bind; + unbind: interfaces.Unbind; + isBound: interfaces.IsBound; + rebind: interfaces.Rebind; + bindApiFactory: BindApiFactory; +}) => void; + +/** + * Factory for an Inversify `ContainerModule` that supports registration of the plugin's + * API factory. Use the `PluginContainerModule`'s `create()` method to create the container + * module; its `callback` function provides a `registry` of Inversify binding functions that + * includes a `bindApiFactory` function for binding the API factory. + */ +export const PluginContainerModule: symbol & { create(callback: PluginContainerModuleCallBack): ContainerModule } = Object.assign(Symbol('PluginContainerModule'), { + create(callback: PluginContainerModuleCallBack): ContainerModule { + const result: InternalPluginContainerModule = new ContainerModule((bind, unbind, isBound, rebind) => { + const bindApiFactory: BindApiFactory = (apiModuleName, serviceIdentifier, factoryClass) => { + result.initializeApi = container => { + const apiCache = new PluginApiCache(apiModuleName, serviceIdentifier); + apiCache.initializeApi(container); + return apiCache; + }; + bind(factoryClass).toSelf().inSingletonScope(); + bind(serviceIdentifier).toDynamicValue(({ container }) => { + const factory = container.get(factoryClass); + return factory.createApi.bind(factory); + }).inSingletonScope(); + }; + callback({ bind, unbind, isBound, rebind, bindApiFactory }); + }); + return result; + } +}); + +/** + * Definition of additional API provided by the `ContainerModule` created by the + * {@link PluginContainerModule} factory function that is for internal use by Theia. + */ +export type InternalPluginContainerModule = ContainerModule & { + /** Use my API factory binding to initialize the plugin API in some `container`. */ + initializeApi?: (container: interfaces.Container) => PluginApiCache; +}; + +/** + * An object that creates and caches the instance of the plugin API created by the + * factory binding in a {@link PluginContainerModule} in some plugin host. + * + * @template T the custom API object's type + */ +export class PluginApiCache { + + private apiFactory: ApiFactory; + private pluginManager: PluginManager; + private defaultApi: T; + private pluginsApiImpl = new Map(); + private hookedModuleLoader = false; + + /** + * Initializes me with the module name by which plugins import the API + * and the service identifier to look up in the Inversify `Container` to + * obtain the {@link ApiFactory} that will instantiate it. + */ + constructor(private readonly apiModuleName: string, + private readonly serviceIdentifier: interfaces.ServiceIdentifier>) {} + + // Called by Theia to do any prep work needed for dishing out the API object + // when it's requested. The key part of that is hooking into the node module + // loader. This is called every time a plugin-host process is forked. + initializeApi(container: interfaces.Container): void { + this.apiFactory = container.get(this.serviceIdentifier); + this.pluginManager = container.get(PluginManager); + + if (!this.hookedModuleLoader) { + this.hookedModuleLoader = true; + this.overrideInternalLoad(); + } + } + + /** + * Hook into the override chain of JavaScript's `module` loading function + * to implement ourselves, using the API provider's registered factory, + * the construction of its default exports object. + */ + private overrideInternalLoad(): void { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const module = require('module'); + + const internalLoad = module._load; + const self = this; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + module._load = function (request: string, parent: any, isMain: any): any { + if (request !== self.apiModuleName) { + // Pass the request to the next implementation down the chain + return internalLoad.call(this, request, parent, isMain); + } + + const plugin = self.findPlugin(parent.filename); + if (plugin) { + let apiImpl = self.pluginsApiImpl.get(plugin.model.id); + if (!apiImpl) { + apiImpl = self.apiFactory(plugin); + self.pluginsApiImpl.set(plugin.model.id, apiImpl); + } + return apiImpl; + } + + console.warn( + `Extension module ${parent.filename} did an import of '${self.apiModuleName}' but our cache ` + + ' has no knowledge of that extension. Returning a generic API object; some functionality might not work correctly.' + ); + if (!self.defaultApi) { + self.defaultApi = self.apiFactory(emptyPlugin); + } + return self.defaultApi; + }; + } + + // Search all loaded plugins to see which one has the given file (absolute path) + protected findPlugin(filePath: string): Plugin | undefined { + return this.pluginManager.getAllPlugins().find(plugin => filePath.startsWith(plugin.pluginFolder)); + } +} diff --git a/packages/plugin-ext/src/plugin/plugin-manager.ts b/packages/plugin-ext/src/plugin/plugin-manager.ts index 6486441dfda89..66e1809848db4 100644 --- a/packages/plugin-ext/src/plugin/plugin-manager.ts +++ b/packages/plugin-ext/src/plugin/plugin-manager.ts @@ -14,8 +14,10 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** +import { injectable, inject, postConstruct } from '@theia/core/shared/inversify'; import { PLUGIN_RPC_CONTEXT, + AbstractPluginManagerExt, NotificationMain, MainMessageType, MessageRegistryMain, @@ -35,13 +37,13 @@ import * as types from './types-impl'; import { join } from './path'; import { EnvExtImpl } from './env'; import { PreferenceRegistryExtImpl } from './preference-registry'; -import { Memento, KeyValueStorageProxy, GlobalState } from './plugin-storage'; +import { InternalStorageExt, Memento, GlobalState } from './plugin-storage'; import { ExtPluginApi } from '../common/plugin-ext-api-contribution'; import { RPCProtocol } from '../common/rpc-protocol'; import { Emitter } from '@theia/core/lib/common/event'; import { WebviewsExtImpl } from './webviews'; import { URI as Uri } from './types-impl'; -import { SecretsExtImpl, SecretStorageExt } from '../plugin/secrets-ext'; +import { InternalSecretsExt, SecretStorageExt } from '../plugin/secrets-ext'; import { PluginExt } from './plugin-context'; import { Deferred } from '@theia/core/lib/common/promise-util'; @@ -77,7 +79,34 @@ class ActivatedPlugin { } } -export class PluginManagerExtImpl implements PluginManagerExt, PluginManager { +export const MinimalTerminalServiceExt = Symbol('MinimalTerminalServiceExt'); +export type MinimalTerminalServiceExt = Pick; + +@injectable() +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export abstract class AbstractPluginManagerExtImpl

> implements AbstractPluginManagerExt

, PluginManager { + + @inject(EnvExtImpl) + protected readonly envExt: EnvExtImpl; + + @inject(MinimalTerminalServiceExt) + protected readonly terminalService: MinimalTerminalServiceExt; + + @inject(InternalStorageExt) + protected readonly storage: InternalStorageExt; + + @inject(InternalSecretsExt) + protected readonly secrets: InternalSecretsExt; + + @inject(LocalizationExt) + protected readonly localization: LocalizationExt; + + @inject(RPCProtocol) + protected readonly rpc: RPCProtocol; + + // Cannot be Inversify-injected because it induces a dependency cycle + protected host: PluginHost; private configStorage: ConfigStorage | undefined; private readonly registry = new Map(); @@ -90,29 +119,22 @@ export class PluginManagerExtImpl implements PluginManagerExt, PluginManager { private onDidChangeEmitter = new Emitter(); private messageRegistryProxy: MessageRegistryMain; private notificationMain: NotificationMain; - private supportedActivationEvents: Set; - protected fireOnDidChange(): void { - this.onDidChangeEmitter.fire(undefined); - } protected jsonValidation: PluginJsonValidationContribution[] = []; protected ready = new Deferred(); - constructor( - private readonly host: PluginHost, - private readonly envExt: EnvExtImpl, - private readonly terminalService: TerminalServiceExt, - private readonly storageProxy: KeyValueStorageProxy, - private readonly secrets: SecretsExtImpl, - private readonly preferencesManager: PreferenceRegistryExtImpl, - private readonly webview: WebviewsExtImpl, - private readonly localization: LocalizationExt, - private readonly rpc: RPCProtocol - ) { + @postConstruct() + initialize(): void { this.messageRegistryProxy = this.rpc.getProxy(PLUGIN_RPC_CONTEXT.MESSAGE_REGISTRY_MAIN); this.notificationMain = this.rpc.getProxy(PLUGIN_RPC_CONTEXT.NOTIFICATION_MAIN); } + setPluginHost(pluginHost: PluginHost): void { + this.host = pluginHost; + } + + abstract $init(params: P): Promise; + async $stop(pluginId?: string): Promise { if (!pluginId) { return this.stopAll(); @@ -179,28 +201,6 @@ export class PluginManagerExtImpl implements PluginManagerExt, PluginManager { } } - async $init(params: PluginManagerInitializeParams): Promise { - this.storageProxy.init(params.globalState, params.workspaceState); - - this.envExt.setQueryParameters(params.env.queryParams); - this.envExt.setLanguage(params.env.language); - this.terminalService.$setShell(params.env.shell); - this.envExt.setUIKind(params.env.uiKind); - this.envExt.setApplicationName(params.env.appName); - this.envExt.setAppHost(params.env.appHost); - - this.preferencesManager.init(params.preferences); - - if (params.extApi) { - this.host.initExtApi(params.extApi); - } - - this.webview.init(params.webview); - this.jsonValidation = params.jsonValidation; - - this.supportedActivationEvents = new Set(params.supportedActivationEvents ?? []); - } - async $start(params: PluginManagerStartParams): Promise { this.configStorage = params.configStorage; @@ -239,15 +239,16 @@ export class PluginManagerExtImpl implements PluginManagerExt, PluginManager { contributes.jsonValidation = (contributes.jsonValidation || []).concat(this.jsonValidation); } this.registry.set(plugin.model.id, plugin); - if (plugin.pluginPath && Array.isArray(plugin.rawModel.activationEvents)) { + const activationEvents = this.getActivationEvents(plugin); + if (plugin.pluginPath && activationEvents) { const activation = () => this.$activatePlugin(plugin.model.id); // an internal activation event is a subject to change this.setActivation(`onPlugin:${plugin.model.id}`, activation); - const unsupportedActivationEvents = plugin.rawModel.activationEvents.filter(e => !this.supportedActivationEvents.has(e.split(':')[0])); + const unsupportedActivationEvents = activationEvents.filter(e => !this.isSupportedActivationEvent(e)); if (unsupportedActivationEvents.length) { console.warn(`Unsupported activation events: ${unsupportedActivationEvents.join(', ')}, please open an issue: https://github.com/eclipse-theia/theia/issues/new`); } - for (let activationEvent of plugin.rawModel.activationEvents) { + for (let activationEvent of activationEvents) { if (activationEvent === 'onUri') { activationEvent = `onUri:theia://${plugin.model.id}`; } @@ -255,6 +256,14 @@ export class PluginManagerExtImpl implements PluginManagerExt, PluginManager { } } } + + protected getActivationEvents(plugin: Plugin): string[] | undefined { + const result = plugin.rawModel.activationEvents; + return Array.isArray(result) ? result : undefined; + } + + protected abstract isSupportedActivationEvent(activationEvent: string): boolean; + protected setActivation(activationEvent: string, activation: () => Promise): void { const activations = this.activations.get(activationEvent) || []; activations.push(activation); @@ -363,11 +372,12 @@ export class PluginManagerExtImpl implements PluginManagerExt, PluginManager { const globalStoragePath = join(configStorage.hostGlobalStoragePath, plugin.model.id); const extension = new PluginExt(this, plugin); const extensionModeValue = plugin.isUnderDevelopment ? types.ExtensionMode.Development : types.ExtensionMode.Production; + const pluginContext: theia.PluginContext = { extensionPath: extension.extensionPath, extensionUri: extension.extensionUri, - globalState: new GlobalState(plugin.model.id, true, this.storageProxy), - workspaceState: new Memento(plugin.model.id, false, this.storageProxy), + globalState: new GlobalState(plugin.model.id, true, this.storage), + workspaceState: new Memento(plugin.model.id, false, this.storage), subscriptions: subscriptions, asAbsolutePath: asAbsolutePath, logPath: logPath, @@ -431,6 +441,49 @@ export class PluginManagerExtImpl implements PluginManagerExt, PluginManager { return this.onDidChangeEmitter.event; } + protected fireOnDidChange(): void { + this.onDidChangeEmitter.fire(undefined); + } + +} + +@injectable() +export class PluginManagerExtImpl extends AbstractPluginManagerExtImpl implements PluginManagerExt { + + @inject(PreferenceRegistryExtImpl) + protected readonly preferencesManager: PreferenceRegistryExtImpl; + + @inject(WebviewsExtImpl) + protected readonly webview: WebviewsExtImpl; + + private supportedActivationEvents: Set; + + async $init(params: PluginManagerInitializeParams): Promise { + this.storage.init(params.globalState, params.workspaceState); + + this.envExt.setQueryParameters(params.env.queryParams); + this.envExt.setUIKind(params.env.uiKind); + this.envExt.setLanguage(params.env.language); + this.terminalService.$setShell(params.env.shell); + this.envExt.setApplicationName(params.env.appName); + this.envExt.setAppHost(params.env.appHost); + + this.preferencesManager.init(params.preferences); + + if (params.extApi) { + this.host.initExtApi(params.extApi); + } + + this.webview.init(params.webview); + this.jsonValidation = params.jsonValidation; + + this.supportedActivationEvents = new Set(params.supportedActivationEvents ?? []); + } + + protected isSupportedActivationEvent(activationEvent: string): boolean { + return this.supportedActivationEvents.has(activationEvent.split(':')[0]); + } + } // for electron diff --git a/packages/plugin-ext/src/plugin/plugin-storage.ts b/packages/plugin-ext/src/plugin/plugin-storage.ts index 9a9449ea44d76..eb2820c8d847e 100644 --- a/packages/plugin-ext/src/plugin/plugin-storage.ts +++ b/packages/plugin-ext/src/plugin/plugin-storage.ts @@ -15,7 +15,8 @@ // ***************************************************************************** import * as theia from '@theia/plugin'; -import { Event, Emitter } from '@theia/core/lib/common/event'; +import { inject, injectable } from '@theia/core/shared/inversify'; +import { Disposable, DisposableGroup, Event, Emitter } from '@theia/core'; import { PLUGIN_RPC_CONTEXT, StorageMain, StorageExt } from '../common/plugin-api-rpc'; import { KeysToAnyValues, KeysToKeysToAnyValue } from '../common/types'; import { RPCProtocol } from '../common/rpc-protocol'; @@ -27,7 +28,7 @@ export class Memento implements theia.Memento { constructor( private readonly pluginId: string, private readonly isPluginGlobalData: boolean, - private readonly storage: KeyValueStorageProxy + private readonly storage: InternalStorageExt ) { this.cache = storage.getPerPluginData(pluginId, isPluginGlobalData); @@ -69,11 +70,28 @@ export class GlobalState extends Memento { setKeysForSync(keys: readonly string[]): void { } } +export const InternalStorageExt = Symbol('InternalStorageExt'); +export interface InternalStorageExt extends StorageExt { + + init(initGlobalData: KeysToKeysToAnyValue, initWorkspaceData: KeysToKeysToAnyValue): void; + + getPerPluginData(key: string, isGlobal: boolean): KeysToAnyValues; + + setPerPluginData(key: string, value: KeysToAnyValues, isGlobal: boolean): Promise; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + storageDataChangedEvent(listener: (e: KeysToKeysToAnyValue) => any, thisArgs?: any, disposables?: DisposableGroup): Disposable; + + $updatePluginsWorkspaceData(workspaceData: KeysToKeysToAnyValue): void; + +} + /** * Singleton. * Is used to proxy storage requests to main side. */ -export class KeyValueStorageProxy implements StorageExt { +@injectable() +export class KeyValueStorageProxy implements InternalStorageExt { private storageDataChangedEmitter = new Emitter(); public readonly storageDataChangedEvent: Event = this.storageDataChangedEmitter.event; @@ -83,7 +101,7 @@ export class KeyValueStorageProxy implements StorageExt { private globalDataCache: KeysToKeysToAnyValue; private workspaceDataCache: KeysToKeysToAnyValue; - constructor(rpc: RPCProtocol) { + constructor(@inject(RPCProtocol) rpc: RPCProtocol) { this.proxy = rpc.getProxy(PLUGIN_RPC_CONTEXT.STORAGE_MAIN); } diff --git a/packages/plugin-ext/src/plugin/preference-registry.spec.ts b/packages/plugin-ext/src/plugin/preference-registry.spec.ts index 8874ccfff41fd..aaba6b4c6f97f 100644 --- a/packages/plugin-ext/src/plugin/preference-registry.spec.ts +++ b/packages/plugin-ext/src/plugin/preference-registry.spec.ts @@ -14,6 +14,7 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** +import { Container } from '@theia/core/shared/inversify'; import { PreferenceRegistryExtImpl, PreferenceScope } from './preference-registry'; import * as chai from 'chai'; import { WorkspaceExtImpl } from '../plugin/workspace'; @@ -38,7 +39,11 @@ describe('PreferenceRegistryExtImpl:', () => { const mockWorkspace: WorkspaceExtImpl = { workspaceFolders: [{ uri: workspaceRoot, name: 'workspace-root', index: 0 }] } as WorkspaceExtImpl; beforeEach(() => { - preferenceRegistryExtImpl = new PreferenceRegistryExtImpl(mockRPC, mockWorkspace); + const container = new Container(); + container.bind(RPCProtocol).toConstantValue(mockRPC); + container.bind(WorkspaceExtImpl).toConstantValue(mockWorkspace); + container.bind(PreferenceRegistryExtImpl).toSelf().inSingletonScope(); + preferenceRegistryExtImpl = container.get(PreferenceRegistryExtImpl); }); describe('Prototype pollution', () => { diff --git a/packages/plugin-ext/src/plugin/preference-registry.ts b/packages/plugin-ext/src/plugin/preference-registry.ts index 465c7122ec4af..7d579ce1c5ef7 100644 --- a/packages/plugin-ext/src/plugin/preference-registry.ts +++ b/packages/plugin-ext/src/plugin/preference-registry.ts @@ -16,6 +16,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import { inject, injectable, postConstruct } from '@theia/core/shared/inversify'; import { Emitter, Event } from '@theia/core/lib/common/event'; import { isOSX, isWindows } from '@theia/core/lib/common/os'; import { URI } from '@theia/core/shared/vscode-uri'; @@ -78,18 +79,23 @@ export class TheiaWorkspace extends Workspace { } } +@injectable() export class PreferenceRegistryExtImpl implements PreferenceRegistryExt { + @inject(RPCProtocol) + protected rpc: RPCProtocol; + + @inject(WorkspaceExtImpl) + protected readonly workspace: WorkspaceExtImpl; + private proxy: PreferenceRegistryMain; private _preferences: Configuration; private readonly _onDidChangeConfiguration = new Emitter(); readonly onDidChangeConfiguration: Event = this._onDidChangeConfiguration.event; - constructor( - rpc: RPCProtocol, - private readonly workspace: WorkspaceExtImpl - ) { - this.proxy = rpc.getProxy(PLUGIN_RPC_CONTEXT.PREFERENCE_REGISTRY_MAIN); + @postConstruct() + initialize(): void { + this.proxy = this.rpc.getProxy(PLUGIN_RPC_CONTEXT.PREFERENCE_REGISTRY_MAIN); } init(data: PreferenceData): void { diff --git a/packages/plugin-ext/src/plugin/secrets-ext.ts b/packages/plugin-ext/src/plugin/secrets-ext.ts index 5bd9a18bbabcf..acfaba814b07c 100644 --- a/packages/plugin-ext/src/plugin/secrets-ext.ts +++ b/packages/plugin-ext/src/plugin/secrets-ext.ts @@ -20,17 +20,37 @@ *--------------------------------------------------------------------------------------------*/ // code copied and modified from https://github.com/microsoft/vscode/blob/1.55.2/src/vs/workbench/api/common/extHostSecrets.ts +import { inject, injectable } from '@theia/core/shared/inversify'; import { Plugin, PLUGIN_RPC_CONTEXT, SecretsExt, SecretsMain } from '../common/plugin-api-rpc'; import { RPCProtocol } from '../common/rpc-protocol'; import { Event, Emitter } from '@theia/core/lib/common/event'; +import { Disposable, DisposableGroup } from '@theia/core'; import * as theia from '@theia/plugin'; -export class SecretsExtImpl implements SecretsExt { +export interface PasswordChange { + extensionId: string; + key: string; +} + +export const InternalSecretsExt = Symbol('InternalSecretsExt'); +export interface InternalSecretsExt extends SecretsExt { + get(extensionId: string, key: string): Promise; + + store(extensionId: string, key: string, value: string): Promise; + + delete(extensionId: string, key: string): Promise; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onDidChangePassword(listener: (e: PasswordChange) => any, thisArgs?: any, disposables?: DisposableGroup): Disposable; +} + +@injectable() +export class SecretsExtImpl implements InternalSecretsExt { private proxy: SecretsMain; - private onDidChangePasswordEmitter = new Emitter<{ extensionId: string, key: string }>(); + private onDidChangePasswordEmitter = new Emitter(); readonly onDidChangePassword = this.onDidChangePasswordEmitter.event; - constructor(rpc: RPCProtocol) { + constructor(@inject(RPCProtocol) rpc: RPCProtocol) { this.proxy = rpc.getProxy(PLUGIN_RPC_CONTEXT.SECRETS_MAIN); } @@ -54,12 +74,12 @@ export class SecretsExtImpl implements SecretsExt { export class SecretStorageExt implements theia.SecretStorage { protected readonly id: string; - readonly secretState: SecretsExtImpl; + readonly secretState: InternalSecretsExt; private onDidChangeEmitter = new Emitter(); readonly onDidChange: Event = this.onDidChangeEmitter.event; - constructor(pluginDescription: Plugin, secretState: SecretsExtImpl) { + constructor(pluginDescription: Plugin, secretState: InternalSecretsExt) { this.id = pluginDescription.model.id.toLowerCase(); this.secretState = secretState; diff --git a/packages/plugin-ext/src/plugin/terminal-ext.ts b/packages/plugin-ext/src/plugin/terminal-ext.ts index 627c322b4ec4b..79219e4103157 100644 --- a/packages/plugin-ext/src/plugin/terminal-ext.ts +++ b/packages/plugin-ext/src/plugin/terminal-ext.ts @@ -14,6 +14,7 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** import { UUID } from '@theia/core/shared/@phosphor/coreutils'; +import { inject, injectable } from '@theia/core/shared/inversify'; import { Terminal, TerminalOptions, PseudoTerminalOptions, ExtensionTerminalOptions, TerminalState } from '@theia/plugin'; import { TerminalServiceExt, TerminalServiceMain, PLUGIN_RPC_CONTEXT } from '../common/plugin-api-rpc'; import { RPCProtocol } from '../common/rpc-protocol'; @@ -46,6 +47,7 @@ export function getIconClass(options: theia.TerminalOptions | theia.ExtensionTer * Provides high level terminal plugin api to use in the Theia plugins. * This service allow(with help proxy) create and use terminal emulator. */ + @injectable() export class TerminalServiceExtImpl implements TerminalServiceExt { private readonly proxy: TerminalServiceMain; @@ -75,7 +77,7 @@ export class TerminalServiceExtImpl implements TerminalServiceExt { private readonly onDidChangeShellEmitter = new Emitter(); readonly onDidChangeShell: theia.Event = this.onDidChangeShellEmitter.event; - constructor(rpc: RPCProtocol) { + constructor(@inject(RPCProtocol) rpc: RPCProtocol) { this.proxy = rpc.getProxy(PLUGIN_RPC_CONTEXT.TERMINAL_MAIN); } diff --git a/packages/plugin-ext/src/plugin/webviews.ts b/packages/plugin-ext/src/plugin/webviews.ts index 8a1fe9da7f6a0..bb86b4903e43a 100644 --- a/packages/plugin-ext/src/plugin/webviews.ts +++ b/packages/plugin-ext/src/plugin/webviews.ts @@ -15,6 +15,7 @@ // ***************************************************************************** import { v4 } from 'uuid'; +import { inject, injectable, postConstruct } from '@theia/core/shared/inversify'; import { Plugin, WebviewsExt, WebviewPanelViewState, WebviewsMain, PLUGIN_RPC_CONTEXT, WebviewInitData, /* WebviewsMain, PLUGIN_RPC_CONTEXT */ } from '../common/plugin-api-rpc'; import * as theia from '@theia/plugin'; import { RPCProtocol } from '../common/rpc-protocol'; @@ -25,8 +26,15 @@ import { WorkspaceExtImpl } from './workspace'; import { PluginIconPath } from './plugin-icon-path'; import { hashValue } from '@theia/core/lib/common/uuid'; +@injectable() export class WebviewsExtImpl implements WebviewsExt { - private readonly proxy: WebviewsMain; + @inject(RPCProtocol) + protected readonly rpc: RPCProtocol; + + @inject(WorkspaceExtImpl) + protected readonly workspace: WorkspaceExtImpl; + + private proxy: WebviewsMain; private readonly webviewPanels = new Map(); private readonly webviews = new Map(); private readonly serializers = new Map(); readonly onDidDispose: Event = this.onDidDisposeEmitter.event; - constructor( - rpc: RPCProtocol, - private readonly workspace: WorkspaceExtImpl, - ) { - this.proxy = rpc.getProxy(PLUGIN_RPC_CONTEXT.WEBVIEWS_MAIN); + @postConstruct() + initialize(): void { + this.proxy = this.rpc.getProxy(PLUGIN_RPC_CONTEXT.WEBVIEWS_MAIN); } init(initData: WebviewInitData): void { diff --git a/packages/plugin-ext/src/plugin/workspace.ts b/packages/plugin-ext/src/plugin/workspace.ts index ea30c844dc6ea..9f8a1d812193d 100644 --- a/packages/plugin-ext/src/plugin/workspace.ts +++ b/packages/plugin-ext/src/plugin/workspace.ts @@ -21,6 +21,7 @@ import * as paths from 'path'; import * as theia from '@theia/plugin'; +import { inject, injectable, postConstruct } from '@theia/core/shared/inversify'; import { Event, Emitter } from '@theia/core/lib/common/event'; import { CancellationToken } from '@theia/core/lib/common/cancellation'; import { @@ -44,8 +45,18 @@ import * as Converter from './type-converters'; import { FileStat } from '@theia/filesystem/lib/common/files'; import { isUndefinedOrNull, isUndefined } from '../common/types'; +@injectable() export class WorkspaceExtImpl implements WorkspaceExt { + @inject(RPCProtocol) + protected readonly rpc: RPCProtocol; + + @inject(EditorsAndDocumentsExtImpl) + protected editorsAndDocuments: EditorsAndDocumentsExtImpl; + + @inject(MessageRegistryExt) + protected messageService: MessageRegistryExt; + private proxy: WorkspaceMain; private workspaceFoldersChangedEmitter = new Emitter(); @@ -63,10 +74,9 @@ export class WorkspaceExtImpl implements WorkspaceExt { private canonicalUriProviders = new Map(); - constructor(rpc: RPCProtocol, - private editorsAndDocuments: EditorsAndDocumentsExtImpl, - private messageService: MessageRegistryExt) { - this.proxy = rpc.getProxy(Ext.WORKSPACE_MAIN); + @postConstruct() + initialize(): void { + this.proxy = this.rpc.getProxy(Ext.WORKSPACE_MAIN); } get rootPath(): string | undefined { diff --git a/sample-plugins/sample-namespace/plugin-gotd/.gitignore b/sample-plugins/sample-namespace/plugin-gotd/.gitignore new file mode 100644 index 0000000000000..aa1ec1ea06181 --- /dev/null +++ b/sample-plugins/sample-namespace/plugin-gotd/.gitignore @@ -0,0 +1 @@ +*.tgz diff --git a/sample-plugins/sample-namespace/plugin-gotd/LICENSE b/sample-plugins/sample-namespace/plugin-gotd/LICENSE new file mode 100644 index 0000000000000..e48e0963459bf --- /dev/null +++ b/sample-plugins/sample-namespace/plugin-gotd/LICENSE @@ -0,0 +1,277 @@ +Eclipse Public License - v 2.0 + + THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE + PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION + OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. + +1. DEFINITIONS + +"Contribution" means: + + a) in the case of the initial Contributor, the initial content + Distributed under this Agreement, and + + b) in the case of each subsequent Contributor: + i) changes to the Program, and + ii) additions to the Program; + where such changes and/or additions to the Program originate from + and are Distributed by that particular Contributor. A Contribution + "originates" from a Contributor if it was added to the Program by + such Contributor itself or anyone acting on such Contributor's behalf. + Contributions do not include changes or additions to the Program that + are not Modified Works. + +"Contributor" means any person or entity that Distributes the Program. + +"Licensed Patents" mean patent claims licensable by a Contributor which +are necessarily infringed by the use or sale of its Contribution alone +or when combined with the Program. + +"Program" means the Contributions Distributed in accordance with this +Agreement. + +"Recipient" means anyone who receives the Program under this Agreement +or any Secondary License (as applicable), including Contributors. + +"Derivative Works" shall mean any work, whether in Source Code or other +form, that is based on (or derived from) the Program and for which the +editorial revisions, annotations, elaborations, or other modifications +represent, as a whole, an original work of authorship. + +"Modified Works" shall mean any work in Source Code or other form that +results from an addition to, deletion from, or modification of the +contents of the Program, including, for purposes of clarity any new file +in Source Code form that contains any contents of the Program. Modified +Works shall not include works that contain only declarations, +interfaces, types, classes, structures, or files of the Program solely +in each case in order to link to, bind by name, or subclass the Program +or Modified Works thereof. + +"Distribute" means the acts of a) distributing or b) making available +in any manner that enables the transfer of a copy. + +"Source Code" means the form of a Program preferred for making +modifications, including but not limited to software source code, +documentation source, and configuration files. + +"Secondary License" means either the GNU General Public License, +Version 2.0, or any later versions of that license, including any +exceptions or additional permissions as identified by the initial +Contributor. + +2. GRANT OF RIGHTS + + a) Subject to the terms of this Agreement, each Contributor hereby + grants Recipient a non-exclusive, worldwide, royalty-free copyright + license to reproduce, prepare Derivative Works of, publicly display, + publicly perform, Distribute and sublicense the Contribution of such + Contributor, if any, and such Derivative Works. + + b) Subject to the terms of this Agreement, each Contributor hereby + grants Recipient a non-exclusive, worldwide, royalty-free patent + license under Licensed Patents to make, use, sell, offer to sell, + import and otherwise transfer the Contribution of such Contributor, + if any, in Source Code or other form. This patent license shall + apply to the combination of the Contribution and the Program if, at + the time the Contribution is added by the Contributor, such addition + of the Contribution causes such combination to be covered by the + Licensed Patents. The patent license shall not apply to any other + combinations which include the Contribution. No hardware per se is + licensed hereunder. + + c) Recipient understands that although each Contributor grants the + licenses to its Contributions set forth herein, no assurances are + provided by any Contributor that the Program does not infringe the + patent or other intellectual property rights of any other entity. + Each Contributor disclaims any liability to Recipient for claims + brought by any other entity based on infringement of intellectual + property rights or otherwise. As a condition to exercising the + rights and licenses granted hereunder, each Recipient hereby + assumes sole responsibility to secure any other intellectual + property rights needed, if any. For example, if a third party + patent license is required to allow Recipient to Distribute the + Program, it is Recipient's responsibility to acquire that license + before distributing the Program. + + d) Each Contributor represents that to its knowledge it has + sufficient copyright rights in its Contribution, if any, to grant + the copyright license set forth in this Agreement. + + e) Notwithstanding the terms of any Secondary License, no + Contributor makes additional grants to any Recipient (other than + those set forth in this Agreement) as a result of such Recipient's + receipt of the Program under the terms of a Secondary License + (if permitted under the terms of Section 3). + +3. REQUIREMENTS + +3.1 If a Contributor Distributes the Program in any form, then: + + a) the Program must also be made available as Source Code, in + accordance with section 3.2, and the Contributor must accompany + the Program with a statement that the Source Code for the Program + is available under this Agreement, and informs Recipients how to + obtain it in a reasonable manner on or through a medium customarily + used for software exchange; and + + b) the Contributor may Distribute the Program under a license + different than this Agreement, provided that such license: + i) effectively disclaims on behalf of all other Contributors all + warranties and conditions, express and implied, including + warranties or conditions of title and non-infringement, and + implied warranties or conditions of merchantability and fitness + for a particular purpose; + + ii) effectively excludes on behalf of all other Contributors all + liability for damages, including direct, indirect, special, + incidental and consequential damages, such as lost profits; + + iii) does not attempt to limit or alter the recipients' rights + in the Source Code under section 3.2; and + + iv) requires any subsequent distribution of the Program by any + party to be under a license that satisfies the requirements + of this section 3. + +3.2 When the Program is Distributed as Source Code: + + a) it must be made available under this Agreement, or if the + Program (i) is combined with other material in a separate file or + files made available under a Secondary License, and (ii) the initial + Contributor attached to the Source Code the notice described in + Exhibit A of this Agreement, then the Program may be made available + under the terms of such Secondary Licenses, and + + b) a copy of this Agreement must be included with each copy of + the Program. + +3.3 Contributors may not remove or alter any copyright, patent, +trademark, attribution notices, disclaimers of warranty, or limitations +of liability ("notices") contained within the Program from any copy of +the Program which they Distribute, provided that Contributors may add +their own appropriate notices. + +4. COMMERCIAL DISTRIBUTION + +Commercial distributors of software may accept certain responsibilities +with respect to end users, business partners and the like. While this +license is intended to facilitate the commercial use of the Program, +the Contributor who includes the Program in a commercial product +offering should do so in a manner which does not create potential +liability for other Contributors. Therefore, if a Contributor includes +the Program in a commercial product offering, such Contributor +("Commercial Contributor") hereby agrees to defend and indemnify every +other Contributor ("Indemnified Contributor") against any losses, +damages and costs (collectively "Losses") arising from claims, lawsuits +and other legal actions brought by a third party against the Indemnified +Contributor to the extent caused by the acts or omissions of such +Commercial Contributor in connection with its distribution of the Program +in a commercial product offering. The obligations in this section do not +apply to any claims or Losses relating to any actual or alleged +intellectual property infringement. In order to qualify, an Indemnified +Contributor must: a) promptly notify the Commercial Contributor in +writing of such claim, and b) allow the Commercial Contributor to control, +and cooperate with the Commercial Contributor in, the defense and any +related settlement negotiations. The Indemnified Contributor may +participate in any such claim at its own expense. + +For example, a Contributor might include the Program in a commercial +product offering, Product X. That Contributor is then a Commercial +Contributor. If that Commercial Contributor then makes performance +claims, or offers warranties related to Product X, those performance +claims and warranties are such Commercial Contributor's responsibility +alone. Under this section, the Commercial Contributor would have to +defend claims against the other Contributors related to those performance +claims and warranties, and if a court requires any other Contributor to +pay any damages as a result, the Commercial Contributor must pay +those damages. + +5. NO WARRANTY + +EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, AND TO THE EXTENT +PERMITTED BY APPLICABLE LAW, THE PROGRAM IS PROVIDED ON AN "AS IS" +BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR +IMPLIED INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR CONDITIONS OF +TITLE, NON-INFRINGEMENT, MERCHANTABILITY OR FITNESS FOR A PARTICULAR +PURPOSE. Each Recipient is solely responsible for determining the +appropriateness of using and distributing the Program and assumes all +risks associated with its exercise of rights under this Agreement, +including but not limited to the risks and costs of program errors, +compliance with applicable laws, damage to or loss of data, programs +or equipment, and unavailability or interruption of operations. + +6. DISCLAIMER OF LIABILITY + +EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, AND TO THE EXTENT +PERMITTED BY APPLICABLE LAW, NEITHER RECIPIENT NOR ANY CONTRIBUTORS +SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING WITHOUT LIMITATION LOST +PROFITS), HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OR DISTRIBUTION OF THE PROGRAM OR THE +EXERCISE OF ANY RIGHTS GRANTED HEREUNDER, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + +7. GENERAL + +If any provision of this Agreement is invalid or unenforceable under +applicable law, it shall not affect the validity or enforceability of +the remainder of the terms of this Agreement, and without further +action by the parties hereto, such provision shall be reformed to the +minimum extent necessary to make such provision valid and enforceable. + +If Recipient institutes patent litigation against any entity +(including a cross-claim or counterclaim in a lawsuit) alleging that the +Program itself (excluding combinations of the Program with other software +or hardware) infringes such Recipient's patent(s), then such Recipient's +rights granted under Section 2(b) shall terminate as of the date such +litigation is filed. + +All Recipient's rights under this Agreement shall terminate if it +fails to comply with any of the material terms or conditions of this +Agreement and does not cure such failure in a reasonable period of +time after becoming aware of such noncompliance. If all Recipient's +rights under this Agreement terminate, Recipient agrees to cease use +and distribution of the Program as soon as reasonably practicable. +However, Recipient's obligations under this Agreement and any licenses +granted by Recipient relating to the Program shall continue and survive. + +Everyone is permitted to copy and distribute copies of this Agreement, +but in order to avoid inconsistency the Agreement is copyrighted and +may only be modified in the following manner. The Agreement Steward +reserves the right to publish new versions (including revisions) of +this Agreement from time to time. No one other than the Agreement +Steward has the right to modify this Agreement. The Eclipse Foundation +is the initial Agreement Steward. The Eclipse Foundation may assign the +responsibility to serve as the Agreement Steward to a suitable separate +entity. Each new version of the Agreement will be given a distinguishing +version number. The Program (including Contributions) may always be +Distributed subject to the version of the Agreement under which it was +received. In addition, after a new version of the Agreement is published, +Contributor may elect to Distribute the Program (including its +Contributions) under the new version. + +Except as expressly stated in Sections 2(a) and 2(b) above, Recipient +receives no rights or licenses to the intellectual property of any +Contributor under this Agreement, whether expressly, by implication, +estoppel or otherwise. All rights in the Program not expressly granted +under this Agreement are reserved. Nothing in this Agreement is intended +to be enforceable by any entity that is not a Contributor or Recipient. +No third-party beneficiary rights are created under this Agreement. + +Exhibit A - Form of Secondary Licenses Notice + +"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: {name license(s), +version(s), and exceptions or additional permissions here}." + + Simply including a copy of this Agreement, including this Exhibit A + is not sufficient to license the Source Code under Secondary Licenses. + + If it is not possible or desirable to put the notice in a particular + file, then You may include the notice in a location (such as a LICENSE + file in a relevant directory) where a recipient would be likely to + look for such a notice. + + You may add additional accurate notices of copyright ownership. diff --git a/sample-plugins/sample-namespace/plugin-gotd/README.md b/sample-plugins/sample-namespace/plugin-gotd/README.md new file mode 100644 index 0000000000000..f16aeb4a4ca6a --- /dev/null +++ b/sample-plugins/sample-namespace/plugin-gotd/README.md @@ -0,0 +1,44 @@ +

+ +
+ +theia-ext-logo + +

ECLIPSE THEIA - EXAMPLE HEADLESS PLUGIN USING THE API PROVIDER SAMPLE

+ +
+ +
+ +## Description + +An example demonstrating three Theia concepts: + +- "headless plugins", being plugins loaded in a single plugin host Node process outside of the context of any frontend connection +- client of a custom "Greeting of the Day" API provided by the `@theia/api-provider-sample` extension +- "backend plugins", being plugins loaded in the backend plugin host process for a frontend connection + +Thus this plug-in demonstrates the capability of a VS Code-compatible plugin to provide two distinct backend entry-points for the two different backend contexts. +As declared in the `package.json` manifest: +- in the headless plugin host, the entry-point script is `headless.js` via the Theia-specific the `"theiaPlugin"` object +- in the backend plugin host for a frontend connection, the entry-point script is `backend.js` via the VS Code standard `"main"` property + +The plugin is for reference and test purposes only and is not published on `npm` (`private: true`). + +### Greeting of the Day + +The sample uses the custom `gotd` API to log a greeting upon activation. + +## Additional Information + +- [Theia - GitHub](https://github.com/eclipse-theia/theia) +- [Theia - Website](https://theia-ide.org/) + +## License + +- [Eclipse Public License 2.0](http://www.eclipse.org/legal/epl-2.0/) +- [一 (Secondary) GNU General Public License, version 2 with the GNU Classpath Exception](https://projects.eclipse.org/license/secondary-gpl-2.0-cp) + +## Trademark +"Theia" is a trademark of the Eclipse Foundation +https://www.eclipse.org/theia diff --git a/sample-plugins/sample-namespace/plugin-gotd/extension.js b/sample-plugins/sample-namespace/plugin-gotd/extension.js new file mode 100644 index 0000000000000..c902426005a8a --- /dev/null +++ b/sample-plugins/sample-namespace/plugin-gotd/extension.js @@ -0,0 +1,41 @@ +// ***************************************************************************** +// Copyright (C) 2023 EclipseSource 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-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +const vscode = require('vscode'); + +function extensionKind(kind) { + switch (kind) { + case vscode.ExtensionKind.UI: + return 'UI'; + case vscode.ExtensionKind.Workspace: + return 'Workspace'; + default: + return 'unknown'; + } +} + +async function activate () { + console.log('[GOTD-BE]', `Running version ${vscode.version} of the VS Code Extension API.`); + console.log('[GOTD-BE]', `It looks like your shell is ${vscode.env.shell}.`); + const myself = vscode.extensions.getExtension('.plugin-gotd'); + if (myself) { + console.log('[GOTD-BE]', `And I am a(n) ${extensionKind(myself.extensionKind)} plugin installed at ${myself.extensionPath}.`); + } +} + +module.exports = { + activate +}; diff --git a/sample-plugins/sample-namespace/plugin-gotd/headless.js b/sample-plugins/sample-namespace/plugin-gotd/headless.js new file mode 100644 index 0000000000000..40a6435927e93 --- /dev/null +++ b/sample-plugins/sample-namespace/plugin-gotd/headless.js @@ -0,0 +1,88 @@ +// ***************************************************************************** +// Copyright (C) 2023 EclipseSource 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-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +const gotd = require('@theia/api-provider-sample'); + +const GreetingKind = gotd.greeting.GreetingKind; + +const toDispose = []; + +function greetingKindsToString(greetingKinds) { + return greetingKinds.map(kind => { + switch (kind) { + case GreetingKind.DIRECT: + return 'DIRECT'; + case GreetingKind.QUIRKY: + return 'QUIRKY'; + case GreetingKind.SNARKY: + return 'SNARKY'; + default: + return ''; + } + }).join(', '); +} + +async function greet(greeter) { + const message = await greeter.getMessage(); + console.log('[GOTD]', message); +} + +let busy = false; +const pending = []; +function later(_fn) { + const task = (fn) => () => { + fn(); + const next = pending.shift(); + if (next) { + setTimeout(task(next), 1000); + } else { + busy = false; + } + } + + if (busy) { + pending.push(_fn); + } else { + busy = true; + setTimeout(task(_fn), 1000); + } +} + +async function activate () { + const greeter = await gotd.greeting.createGreeter(); + toDispose.push(greeter.onGreetingKindsChanged( + kinds => { + console.log('[GOTD]', + `Now supporting these kinds of greeting: ${greetingKindsToString(kinds)}.`); + if (kinds.length > 0) { + greet(greeter); + } + })); + + greet(greeter); + + later(() => greeter.setGreetingKind(GreetingKind.DIRECT, false)); + later(() => greeter.setGreetingKind(GreetingKind.QUIRKY)); + later(() => greeter.setGreetingKind(GreetingKind.SNARKY)); +} + +module.exports = { + activate, + deactivate: function () { + console.log('[GOTD]', 'Cleaning up.'); + toDispose.forEach(d => d.dispose()); + } +}; diff --git a/sample-plugins/sample-namespace/plugin-gotd/package.json b/sample-plugins/sample-namespace/plugin-gotd/package.json new file mode 100644 index 0000000000000..98413ec1b3b91 --- /dev/null +++ b/sample-plugins/sample-namespace/plugin-gotd/package.json @@ -0,0 +1,34 @@ +{ + "private": true, + "name": "plugin-gotd", + "version": "1.45.0", + "license": "EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0", + "repository": { + "type": "git", + "url": "https://github.com/eclipse-theia/theia.git" + }, + "engines": { + "vscode": "^1.84.0" + }, + "main": "extension", + "activationEvents": [ + "*" + ], + "devDependencies": { + "@theia/api-provider-sample": "^1.45.0" + }, + "scripts": { + "prepare": "yarn -s package", + "package": "yarn pack" + }, + "theiaPlugin": { + "headless": "headless" + }, + "headless": { + "activationEvents": [ + "*" + ], + "contributes": { + } + } +} diff --git a/tsconfig.json b/tsconfig.json index ac07be3ca619e..3b0eb7ae410c0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "extends": "./configs/base.tsconfig.json", - "include": [ ], + "include": [], "compilerOptions": { "composite": true, "allowJs": true @@ -36,6 +36,9 @@ { "path": "dev-packages/request" }, + { + "path": "examples/api-provider-sample" + }, { "path": "examples/api-samples" }, @@ -129,6 +132,9 @@ { "path": "packages/plugin-ext" }, + { + "path": "packages/plugin-ext-headless" + }, { "path": "packages/plugin-ext-vscode" },